From 2f77c86275657f8a96aa63a1a5948bf38a2bf1b3 Mon Sep 17 00:00:00 2001 From: "Cedric Prieels (cpr)" Date: Thu, 2 Sep 2021 12:54:21 +0200 Subject: [PATCH] [IMP] sale_timesheet: remove overview and improve updates This commit removes the project overview feature and views. This commit improves the project updates right panel informations and adds the buttons added in the project form view of this module. /!\ This commit also improves the timesheet_invoice_type granularity and precision. The project overview view lacks of genericity and lacks of clarity regarding the information to report. PR : #72736 See odoo/upgrade#2706 task-2545084 --- addons/sale_timesheet/__manifest__.py | 3 - addons/sale_timesheet/models/__init__.py | 1 - addons/sale_timesheet/models/account.py | 20 +- addons/sale_timesheet/models/project.py | 210 ++++--- .../sale_timesheet/models/project_overview.py | 580 ------------------ .../sale_timesheet/models/project_update.py | 99 +-- addons/sale_timesheet/models/sale_order.py | 9 - ...ct_profitability_report_analysis_views.xml | 27 - .../static/src/js/project_overview.js | 56 -- .../static/src/scss/sale_timesheet.scss | 144 ----- .../static/src/xml/sale_project_templates.xml | 78 ++- .../static/tests/tours/sale_timesheet_tour.js | 46 +- addons/sale_timesheet/tests/__init__.py | 1 - .../tests/test_project_overview.py | 130 ---- .../tests/test_project_update.py | 95 +-- .../tests/test_sale_timesheet.py | 6 +- .../views/hr_timesheet_templates.xml | 486 --------------- .../views/project_task_views.xml | 25 +- .../views/project_update_templates.xml | 54 +- 19 files changed, 329 insertions(+), 1741 deletions(-) delete mode 100644 addons/sale_timesheet/models/project_overview.py delete mode 100644 addons/sale_timesheet/static/src/js/project_overview.js delete mode 100644 addons/sale_timesheet/static/src/scss/sale_timesheet.scss delete mode 100644 addons/sale_timesheet/tests/test_project_overview.py delete mode 100644 addons/sale_timesheet/views/hr_timesheet_templates.xml diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py index 5491dc2ab4f..d488327dde0 100644 --- a/addons/sale_timesheet/__manifest__.py +++ b/addons/sale_timesheet/__manifest__.py @@ -25,7 +25,6 @@ have real delivered quantities in sales orders. 'views/project_update_templates.xml', 'views/hr_timesheet_views.xml', 'views/res_config_settings_views.xml', - 'views/hr_timesheet_templates.xml', 'views/sale_timesheet_portal_templates.xml', 'views/project_sharing_views.xml', 'report/project_profitability_report_analysis_views.xml', @@ -44,8 +43,6 @@ have real delivered quantities in sales orders. 'sale_timesheet/static/src/scss/sale_timesheet_portal.scss', ], 'web.assets_backend': [ - 'sale_timesheet/static/src/scss/sale_timesheet.scss', - 'sale_timesheet/static/src/js/project_overview.js', 'sale_timesheet/static/src/js/so_line_one2many.js', ], 'web.assets_tests': [ diff --git a/addons/sale_timesheet/models/__init__.py b/addons/sale_timesheet/models/__init__.py index 9a1815b27ba..200ad6e7629 100644 --- a/addons/sale_timesheet/models/__init__.py +++ b/addons/sale_timesheet/models/__init__.py @@ -5,7 +5,6 @@ from . import account from . import account_move from . import product from . import project -from . import project_overview from . import project_update from . import sale_order from . import res_config_settings diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py index 8b9e65168b4..a1336dd2c0b 100644 --- a/addons/sale_timesheet/models/account.py +++ b/addons/sale_timesheet/models/account.py @@ -18,7 +18,11 @@ class AccountAnalyticLine(models.Model): ('billable_time', 'Billed on Timesheets'), ('billable_fixed', 'Billed at a Fixed price'), ('non_billable', 'Non Billable Tasks'), - ('non_billable_project', 'No task found')], string="Billable Type", compute='_compute_timesheet_invoice_type', compute_sudo=True, store=True, readonly=True) + ('timesheet_revenues', 'Timesheet Revenues'), + ('service_revenues', 'Service Revenues'), + ('other_revenues', 'Other Revenues'), + ('other_costs', 'Other Costs')], string="Billable Type", + compute='_compute_timesheet_invoice_type', compute_sudo=True, store=True, readonly=True) commercial_partner_id = fields.Many2one('res.partner', compute="_compute_commercial_partner") timesheet_invoice_id = fields.Many2one('account.move', string="Invoice", readonly=True, copy=False, help="Invoice created from the timesheet") so_line = fields.Many2one(compute="_compute_so_line", store=True, readonly=False, domain="[('is_service', '=', True), ('is_expense', '=', False), ('state', 'in', ['sale', 'done']), ('order_partner_id', 'child_of', commercial_partner_id)]") @@ -37,23 +41,25 @@ class AccountAnalyticLine(models.Model): if not self.is_so_line_edited: self.is_so_line_edited = True - # TODO: [XBO] Since the task_id is not required in this model, then it should more efficient to depends to pricing_type of project (See in master) - @api.depends('so_line.product_id', 'project_id', 'task_id', 'task_id.pricing_type') + @api.depends('so_line.product_id', 'project_id', 'amount') def _compute_timesheet_invoice_type(self): for timesheet in self: if timesheet.project_id: # AAL will be set to False - invoice_type = 'non_billable_project' if not timesheet.task_id else 'non_billable' - if timesheet.task_id and timesheet.so_line.product_id.type == 'service': + invoice_type = 'non_billable' if not timesheet.so_line else False + if timesheet.so_line and timesheet.so_line.product_id.type == 'service': if timesheet.so_line.product_id.invoice_policy == 'delivery': if timesheet.so_line.product_id.service_type == 'timesheet': - invoice_type = 'billable_time' + invoice_type = 'timesheet_revenues' if timesheet.amount > 0 else 'billable_time' else: invoice_type = 'billable_fixed' elif timesheet.so_line.product_id.invoice_policy == 'order': invoice_type = 'billable_fixed' timesheet.timesheet_invoice_type = invoice_type else: - timesheet.timesheet_invoice_type = False + if timesheet.so_line and timesheet.so_line.product_id.type == 'service': + timesheet.timesheet_invoice_type = 'service_revenues' + else: + timesheet.timesheet_invoice_type = 'other_revenues' if timesheet.amount >= 0 else 'other_costs' @api.depends('task_id.sale_line_id', 'project_id.sale_line_id', 'employee_id', 'project_id.allow_billable') def _compute_so_line(self): diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 217c9fe5040..3f971dd9fa3 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -1,14 +1,13 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import json 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 - +from odoo.tools import format_amount, float_is_zero # YTI PLEASE SPLIT ME class Project(models.Model): @@ -39,6 +38,9 @@ class Project(models.Model): help="Employee/Sale Order Item Mapping:\n Defines to which sales order item an employee's timesheet entry will be linked." "By extension, it defines the rate at which an employee's time on the project is billed.") allow_billable = fields.Boolean("Billable", help="Invoice your time and material from tasks.") + billable_percentage = fields.Integer( + compute='_compute_billable_percentage', + help="% of timesheets that are billable compared to the total number of timesheets linked to the AA of the project, rounded to the unit.") display_create_order = fields.Boolean(compute='_compute_display_create_order') timesheet_product_id = fields.Many2one( 'product.product', string='Timesheet Product', @@ -108,6 +110,21 @@ class Project(models.Model): domain = expression.AND([domain, [('allow_billable', '=', True)]]) return domain + @api.depends('analytic_account_id', 'timesheet_ids') + def _compute_billable_percentage(self): + timesheets_read_group = self.env['account.analytic.line'].read_group([('project_id', 'in', self.ids)], ['project_id', 'so_line', 'unit_amount'], ['project_id', 'so_line'], lazy=False) + timesheets_by_project = defaultdict(list) + for res in timesheets_read_group: + timesheets_by_project[res['project_id'][0]].append((res['unit_amount'], bool(res['so_line']))) + for project in self: + timesheet_total = timesheet_billable = 0.0 + for unit_amount, is_billable_timesheet in timesheets_by_project[project.id]: + timesheet_total += unit_amount + if is_billable_timesheet: + timesheet_billable += unit_amount + billable_percentage = timesheet_billable / timesheet_total * 100 if timesheet_total > 0 else 0 + project.billable_percentage = round(billable_percentage) + @api.depends('partner_id', 'pricing_type') def _compute_display_create_order(self): for project in self: @@ -134,12 +151,6 @@ class Project(models.Model): (self - projects).warning_employee_rate = False - @api.depends('analytic_account_id', 'allow_billable', 'allow_timesheets') - def _compute_project_overview(self): - super()._compute_project_overview() - for project in self.filtered(lambda p: not p.project_overview): - project.project_overview = project.allow_billable or project.allow_timesheets - @api.depends('sale_line_employee_ids.sale_line_id', 'sale_line_id') def _compute_partner_id(self): for project in self: @@ -188,10 +199,29 @@ class Project(models.Model): sale_line_id = project.sale_line_employee_ids.filtered(lambda l: l.project_id == project and l.employee_id == employee_id).sale_line_id timesheet_ids.filtered(lambda t: t.employee_id == employee_id).sudo().so_line = sale_line_id + def action_open_project_invoices(self): + invoices = self.env['account.move'].search([ + ('line_ids.analytic_account_id', '!=', False), + ('line_ids.analytic_account_id', 'in', self.analytic_account_id.ids), + ('move_type', '=', 'out_invoice') + ]) + action = { + 'name': _('Invoices'), + 'type': 'ir.actions.act_window', + 'res_model': 'account.move', + 'views': [[False, 'tree'], [False, 'form'], [False, 'kanban']], + 'domain': [('id', 'in', invoices.ids)], + 'context': { + 'create': False, + } + } + if len(invoices) == 1: + action['views'] = [[False, 'form']] + action['res_id'] = invoices.id + return action + def action_view_timesheet(self): self.ensure_one() - if self.allow_timesheets: - return self.action_view_timesheet_plan() return { 'type': 'ir.actions.act_window', 'name': _('Timesheets of %s', self.name), @@ -215,18 +245,6 @@ class Project(models.Model): } } - def action_view_timesheet_plan(self): - action = self.env["ir.actions.actions"]._for_xml_id("sale_timesheet.project_timesheet_action_client_timesheet_plan") - action['params'] = { - 'project_ids': self.ids, - } - action['context'] = { - 'active_id': self.id, - 'active_ids': self.ids, - 'search_default_name': self.name, - } - return action - def action_make_billable(self): return { "name": _("Create Sales Order"), @@ -241,6 +259,27 @@ class Project(models.Model): }, } + def action_billable_time_button(self): + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id("hr_timesheet.timesheet_action_all") + action.update({ + 'context': { + 'search_default_groupby_timesheet_invoice_type': True, + 'default_project_id': self.id, + }, + 'domain': [('project_id', '=', self.id)], + 'view_mode': 'tree,grid,kanban,pivot,graph,form', + 'views': [ + [self.env.ref('hr_timesheet.timesheet_view_tree_user').id, 'tree'], + [self.env.ref('timesheet_grid.timesheet_view_grid_by_employee').id, 'grid'], + [self.env.ref('hr_timesheet.view_kanban_account_analytic_line').id, 'kanban'], + [self.env.ref('hr_timesheet.view_hr_timesheet_line_pivot').id, 'pivot'], + [self.env.ref('hr_timesheet.view_hr_timesheet_line_graph_all').id, 'graph'], + [self.env.ref('timesheet_grid.timesheet_view_form').id, 'form'], + ], + }) + return action + # ---------------------------- # Project Updates # ---------------------------- @@ -249,26 +288,52 @@ class Project(models.Model): panel_data = super(Project, self).get_panel_data() return { **panel_data, + 'analytic_account_id': self.analytic_account_id.id, + 'sold_items': self._get_sold_items(), 'profitability_items': self._get_profitability_items() } + def _get_sale_order_lines(self): + sale_orders = self.sale_order_id | self.tasks.sale_order_id + return self.env['sale.order.line'].search([('order_id', 'in', sale_orders.ids), ('is_service', '=', True)], order='id asc') + + def _get_sold_items(self): + sols = self._get_sale_order_lines() + number_sale_orders = len(sols.order_id) + sold_items = { + 'allow_billable': self.allow_billable, + 'data': [], + 'number_sols': len(sols), + 'total_sold': 0, + 'effective_sold': 0, + 'company_unit_name': self.env.company.timesheet_encode_uom_id.name + } + for sol in sols: + name = [x[1] for x in sol.name_get()] if number_sale_orders > 1 else sol.name + qty_delivered = int(sol.qty_delivered) if sol.qty_delivered.is_integer() else sol.qty_delivered + product_uom_qty = int(sol.product_uom_qty) if sol.product_uom_qty.is_integer() else sol.product_uom_qty + sold_items['data'].append({ + 'name': name, + 'value': '%s / %s %s' % (qty_delivered, product_uom_qty, sol.product_uom.name), + 'color': 'red' if qty_delivered > product_uom_qty else 'black' + }) + #We only want to consider hours and days for this calculation + if sol.product_uom.category_id == self.env.company.timesheet_encode_uom_id.category_id: + sold_items['total_sold'] += sol.product_uom._compute_quantity(sol.product_uom_qty, self.env.company.timesheet_encode_uom_id, raise_if_failure=False) + sold_items['effective_sold'] = round(sol.product_uom._compute_quantity(self.total_timesheet_time, self.env.company.timesheet_encode_uom_id, raise_if_failure=False), 2) + remaining = sold_items['total_sold'] - sold_items['effective_sold'] + sold_items['remaining'] = { + 'value': remaining, + 'color': 'red' if remaining < 0 else 'black', + } + return sold_items + def _get_profitability_items(self): if not self.user_has_groups('project.group_project_manager'): return {'data': []} - 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 timesheets: - 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: + profitability = self._get_profitability_common() margin_color = False if not float_is_zero(profitability['margin'], precision_digits=0): margin_color = profitability['margin'] > 0 and 'green' or 'red' @@ -289,51 +354,54 @@ class Project(models.Model): 'data': data, } - def _get_profitability_common(self, costs_revenues=True, timesheets=True): + def _get_profitability_common(self): self.ensure_one() result = { 'costs': 0.0, 'margin': 0.0, - 'revenues': 0.0, - 'timesheet_unit_amount': 0.0, - 'timesheet_percentage_billable': 0.0, + 'revenues': 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'] + + 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({ - '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, + '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']), }) return result + def _get_stat_buttons(self): + buttons = super(Project, self)._get_stat_buttons() + buttons.append({ + 'icon': 'clock-o', + 'text': _('Billable Time'), + 'number': '%s %%' % (self.billable_percentage), + 'action_type': 'object', + 'action': 'action_billable_time_button', + 'additional_context': json.dumps({ + 'active_id': self.id, + 'default_project_id': self.id + }), + 'show': self.user_has_groups('hr_timesheet.group_hr_timesheet_approver') and self.allow_timesheets and bool(self.analytic_account_id), + 'sequence': 9, + }) + return buttons + class ProjectTask(models.Model): _inherit = "project.task" diff --git a/addons/sale_timesheet/models/project_overview.py b/addons/sale_timesheet/models/project_overview.py deleted file mode 100644 index d9ad4175ed5..00000000000 --- a/addons/sale_timesheet/models/project_overview.py +++ /dev/null @@ -1,580 +0,0 @@ -# -*- coding: utf-8 -*- -import babel.dates -from dateutil.relativedelta import relativedelta -import itertools -import json - -from odoo import fields, _, models -from odoo.osv import expression -from odoo.tools import float_round -from odoo.tools.misc import get_lang - -from odoo.addons.web.controllers.main import clean_action -from datetime import date - -DEFAULT_MONTH_RANGE = 3 - - -class Project(models.Model): - _inherit = 'project.project' - - - def _qweb_prepare_qcontext(self, view_id, domain): - values = super()._qweb_prepare_qcontext(view_id, domain) - - projects = self.search(domain) - values.update(projects._plan_prepare_values()) - values['actions'] = projects._plan_prepare_actions(values) - - return values - - def _plan_get_employee_ids(self): - aal_employee_ids = self.env['account.analytic.line'].read_group([('project_id', 'in', self.ids), ('employee_id', '!=', False)], ['employee_id'], ['employee_id']) - employee_ids = list(map(lambda x: x['employee_id'][0], aal_employee_ids)) - return employee_ids - - def _plan_prepare_values(self): - currency = self.env.company.currency_id - uom_hour = self.env.ref('uom.product_uom_hour') - company_uom = self.env.company.timesheet_encode_uom_id - is_uom_day = company_uom == self.env.ref('uom.product_uom_day') - hour_rounding = uom_hour.rounding - billable_types = ['non_billable', 'non_billable_project', 'billable_time', 'non_billable_timesheet', 'billable_fixed'] - - values = { - 'projects': self, - 'currency': currency, - 'timesheet_domain': [('project_id', 'in', self.ids)], - 'profitability_domain': [('project_id', 'in', self.ids)], - 'stat_buttons': self._plan_get_stat_button(), - 'is_uom_day': is_uom_day, - } - - # - # Hours, Rates and Profitability - # - dashboard_values = { - 'time': dict.fromkeys(billable_types + ['total'], 0.0), - 'rates': dict.fromkeys(billable_types + ['total'], 0.0), - 'profit': { - 'invoiced': 0.0, - 'to_invoice': 0.0, - 'cost': 0.0, - 'total': 0.0, - } - } - - # hours from non-invoiced timesheets that are linked to canceled so - canceled_hours_domain = [('project_id', 'in', self.ids), ('timesheet_invoice_type', '!=', False), ('so_line.state', '=', 'cancel')] - total_canceled_hours = sum(self.env['account.analytic.line'].search(canceled_hours_domain).mapped('unit_amount')) - canceled_hours = float_round(total_canceled_hours, precision_rounding=hour_rounding) - if is_uom_day: - # convert time from hours to days - canceled_hours = round(uom_hour._compute_quantity(canceled_hours, company_uom, raise_if_failure=False), 2) - dashboard_values['time']['canceled'] = canceled_hours - dashboard_values['time']['total'] += canceled_hours - - # hours (from timesheet) and rates (by billable type) - dashboard_domain = [('project_id', 'in', self.ids), ('timesheet_invoice_type', '!=', False), '|', ('so_line', '=', False), ('so_line.state', '!=', 'cancel')] # force billable type - dashboard_data = self.env['account.analytic.line'].read_group(dashboard_domain, ['unit_amount', 'timesheet_invoice_type'], ['timesheet_invoice_type']) - dashboard_total_hours = sum([data['unit_amount'] for data in dashboard_data]) + total_canceled_hours - for data in dashboard_data: - billable_type = data['timesheet_invoice_type'] - amount = float_round(data.get('unit_amount'), precision_rounding=hour_rounding) - if is_uom_day: - # convert time from hours to days - amount = round(uom_hour._compute_quantity(amount, company_uom, raise_if_failure=False), 2) - dashboard_values['time'][billable_type] = amount - dashboard_values['time']['total'] += amount - # rates - rate = round(data.get('unit_amount') / dashboard_total_hours * 100, 2) if dashboard_total_hours else 0.0 - dashboard_values['rates'][billable_type] = rate - dashboard_values['rates']['total'] += rate - dashboard_values['time']['total'] = round(dashboard_values['time']['total'], 2) - - # rates from non-invoiced timesheets that are linked to canceled so - dashboard_values['rates']['canceled'] = float_round(100 * total_canceled_hours / (dashboard_total_hours or 1), precision_rounding=hour_rounding) - # profitability, using profitability SQL report - field_map = { - 'amount_untaxed_invoiced': 'invoiced', - 'amount_untaxed_to_invoice': 'to_invoice', - 'timesheet_cost': 'cost', - 'expense_cost': 'expense_cost', - 'expense_amount_untaxed_invoiced': 'expense_amount_untaxed_invoiced', - 'other_revenues': 'other_revenues' - } - profit = dict.fromkeys(list(field_map.values()) + ['other_revenues', 'total'], 0.0) - profitability_raw_data = self.env['project.profitability.report'].read_group([('project_id', 'in', self.ids)], ['project_id'] + list(field_map), ['project_id']) - for data in profitability_raw_data: - company_id = self.env['project.project'].browse(data.get('project_id')[0]).company_id - from_currency = company_id.currency_id - for field in field_map: - value = data.get(field, 0.0) - if from_currency != currency: - value = from_currency._convert(value, currency, company_id, date.today()) - profit[field_map[field]] += value - profit['total'] = sum([profit[item] for item in profit.keys()]) - dashboard_values['profit'] = profit - - values['dashboard'] = dashboard_values - - # - # Time Repartition (per employee per billable types) - # - employee_ids = self._plan_get_employee_ids() - employee_ids = list(set(employee_ids)) - # Retrieve the employees for which the current user can see theirs timesheets - employee_domain = expression.AND([[('company_id', 'in', self.env.companies.ids)], self.env['account.analytic.line']._domain_employee_id()]) - employees = self.env['hr.employee'].sudo().browse(employee_ids).filtered_domain(employee_domain) - repartition_domain = [('project_id', 'in', self.ids), ('employee_id', '!=', False), ('timesheet_invoice_type', '!=', False)] # force billable type - # repartition data, without timesheet on cancelled so - repartition_data = self.env['account.analytic.line'].read_group(repartition_domain + ['|', ('so_line', '=', False), ('so_line.state', '!=', 'cancel')], ['employee_id', 'timesheet_invoice_type', 'unit_amount'], ['employee_id', 'timesheet_invoice_type'], lazy=False) - # read timesheet on cancelled so - cancelled_so_timesheet = self.env['account.analytic.line'].read_group(repartition_domain + [('so_line.state', '=', 'cancel')], ['employee_id', 'unit_amount'], ['employee_id'], lazy=False) - repartition_data += [{**canceled, 'timesheet_invoice_type': 'canceled'} for canceled in cancelled_so_timesheet] - - # set repartition per type per employee - repartition_employee = {} - for employee in employees: - repartition_employee[employee.id] = dict( - employee_id=employee.id, - employee_name=employee.name, - non_billable_project=0.0, - non_billable=0.0, - billable_time=0.0, - non_billable_timesheet=0.0, - billable_fixed=0.0, - canceled=0.0, - total=0.0, - ) - for data in repartition_data: - employee_id = data['employee_id'][0] - repartition_employee.setdefault(employee_id, dict( - employee_id=data['employee_id'][0], - employee_name=data['employee_id'][1], - non_billable_project=0.0, - non_billable=0.0, - billable_time=0.0, - non_billable_timesheet=0.0, - billable_fixed=0.0, - canceled=0.0, - total=0.0, - ))[data['timesheet_invoice_type']] = float_round(data.get('unit_amount', 0.0), precision_rounding=hour_rounding) - repartition_employee[employee_id]['__domain_' + data['timesheet_invoice_type']] = data['__domain'] - # compute total - for employee_id, vals in repartition_employee.items(): - repartition_employee[employee_id]['total'] = sum([vals[inv_type] for inv_type in [*billable_types, 'canceled']]) - if is_uom_day: - # convert all times from hours to days - for time_type in ['non_billable_project', 'non_billable', 'billable_time', 'non_billable_timesheet', 'billable_fixed', 'canceled', 'total']: - if repartition_employee[employee_id][time_type]: - repartition_employee[employee_id][time_type] = round(uom_hour._compute_quantity(repartition_employee[employee_id][time_type], company_uom, raise_if_failure=False), 2) - hours_per_employee = [repartition_employee[employee_id]['total'] for employee_id in repartition_employee] - values['repartition_employee_max'] = (max(hours_per_employee) if hours_per_employee else 1) or 1 - values['repartition_employee'] = repartition_employee - - # - # Table grouped by SO / SOL / Employees - # - timesheet_forecast_table_rows = self._table_get_line_values(employees) - if timesheet_forecast_table_rows: - values['timesheet_forecast_table'] = timesheet_forecast_table_rows - return values - - def _table_get_line_values(self, employees=None): - """ return the header and the rows informations of the table """ - if not self: - return False - - uom_hour = self.env.ref('uom.product_uom_hour') - company_uom = self.env.company.timesheet_encode_uom_id - is_uom_day = company_uom and company_uom == self.env.ref('uom.product_uom_day') - - # build SQL query and fetch raw data - query, query_params = self._table_rows_sql_query() - self.env.cr.execute(query, query_params) - raw_data = self.env.cr.dictfetchall() - rows_employee = self._table_rows_get_employee_lines(raw_data) - default_row_vals = self._table_row_default() - - empty_line_ids, empty_order_ids = self._table_get_empty_so_lines() - - # extract row labels - sale_line_ids = set() - sale_order_ids = set() - for key_tuple, row in rows_employee.items(): - if row[0]['sale_line_id']: - sale_line_ids.add(row[0]['sale_line_id']) - if row[0]['sale_order_id']: - sale_order_ids.add(row[0]['sale_order_id']) - - sale_orders = self.env['sale.order'].sudo().browse(sale_order_ids | empty_order_ids) - sale_order_lines = self.env['sale.order.line'].sudo().browse(sale_line_ids | empty_line_ids) - map_so_names = {so.id: so.name for so in sale_orders} - map_so_cancel = {so.id: so.state == 'cancel' for so in sale_orders} - map_sol = {sol.id: sol for sol in sale_order_lines} - map_sol_names = {sol.id: sol.name.split('\n')[0] if sol.name else _('No Sales Order Line') for sol in sale_order_lines} - map_sol_so = {sol.id: sol.order_id.id for sol in sale_order_lines} - - rows_sale_line = {} # (so, sol) -> [INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted] - for sale_line_id in empty_line_ids: # add service SO line having no timesheet - sale_line_row_key = (map_sol_so.get(sale_line_id), sale_line_id) - sale_line = map_sol.get(sale_line_id) - is_milestone = sale_line.product_id.invoice_policy == 'delivery' and sale_line.product_id.service_type == 'manual' if sale_line else False - rows_sale_line[sale_line_row_key] = [{'label': map_sol_names.get(sale_line_id, _('No Sales Order Line')), 'res_id': sale_line_id, 'res_model': 'sale.order.line', 'type': 'sale_order_line', 'is_milestone': is_milestone}] + default_row_vals[:] - if not is_milestone: - rows_sale_line[sale_line_row_key][-2] = sale_line.product_uom._compute_quantity(sale_line.product_uom_qty, uom_hour, raise_if_failure=False) if sale_line else 0.0 - - rows_sale_line_all_data = {} - if not employees: - employees = self.env['hr.employee'].sudo().search(self.env['account.analytic.line']._domain_employee_id()) - for row_key, row_employee in rows_employee.items(): - sale_order_id, sale_line_id, employee_id = row_key - # sale line row - sale_line_row_key = (sale_order_id, sale_line_id) - if sale_line_row_key not in rows_sale_line: - sale_line = map_sol.get(sale_line_id, self.env['sale.order.line']) - is_milestone = sale_line.product_id.invoice_policy == 'delivery' and sale_line.product_id.service_type == 'manual' if sale_line else False - rows_sale_line[sale_line_row_key] = [{'label': map_sol_names.get(sale_line.id) if sale_line else _('No Sales Order Line'), 'res_id': sale_line_id, 'res_model': 'sale.order.line', 'type': 'sale_order_line', 'is_milestone': is_milestone}] + default_row_vals[:] # INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted - if not is_milestone: - rows_sale_line[sale_line_row_key][-2] = sale_line.product_uom._compute_quantity(sale_line.product_uom_qty, uom_hour, raise_if_failure=False) if sale_line else 0.0 - - if sale_line_row_key not in rows_sale_line_all_data: - rows_sale_line_all_data[sale_line_row_key] = [0] * len(row_employee) - for index in range(1, len(row_employee)): - if employee_id in employees.ids: - rows_sale_line[sale_line_row_key][index] += row_employee[index] - rows_sale_line_all_data[sale_line_row_key][index] += row_employee[index] - if not rows_sale_line[sale_line_row_key][0].get('is_milestone'): - rows_sale_line[sale_line_row_key][-1] = rows_sale_line[sale_line_row_key][-2] - rows_sale_line_all_data[sale_line_row_key][5] - else: - rows_sale_line[sale_line_row_key][-1] = 0 - - rows_sale_order = {} # so -> [INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted] - for row_key, row_sale_line in rows_sale_line.items(): - sale_order_id = row_key[0] - # sale order row - if sale_order_id not in rows_sale_order: - rows_sale_order[sale_order_id] = [{'label': map_so_names.get(sale_order_id, _('No Sales Order')), 'canceled': map_so_cancel.get(sale_order_id, False), 'res_id': sale_order_id, 'res_model': 'sale.order', 'type': 'sale_order'}] + default_row_vals[:] # INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted - - for index in range(1, len(row_sale_line)): - rows_sale_order[sale_order_id][index] += row_sale_line[index] - - # group rows SO, SOL and their related employee rows. - timesheet_forecast_table_rows = [] - for sale_order_id, sale_order_row in rows_sale_order.items(): - timesheet_forecast_table_rows.append(sale_order_row) - for sale_line_row_key, sale_line_row in rows_sale_line.items(): - if sale_order_id == sale_line_row_key[0]: - sale_order_row[0]['has_children'] = True - timesheet_forecast_table_rows.append(sale_line_row) - for employee_row_key, employee_row in rows_employee.items(): - if sale_order_id == employee_row_key[0] and sale_line_row_key[1] == employee_row_key[1] and employee_row_key[2] in employees.ids: - sale_line_row[0]['has_children'] = True - timesheet_forecast_table_rows.append(employee_row) - - if is_uom_day: - # convert all values from hours to days - for row in timesheet_forecast_table_rows: - for index in range(1, len(row)): - row[index] = round(uom_hour._compute_quantity(row[index], company_uom, raise_if_failure=False), 2) - # complete table data - return { - 'header': self._table_header(), - 'rows': timesheet_forecast_table_rows - } - def _table_header(self): - initial_date = fields.Date.from_string(fields.Date.today()) - ts_months = sorted([fields.Date.to_string(initial_date - relativedelta(months=i, day=1)) for i in range(0, DEFAULT_MONTH_RANGE)]) # M1, M2, M3 - - def _to_short_month_name(date): - month_index = fields.Date.from_string(date).month - return babel.dates.get_month_names('abbreviated', locale=get_lang(self.env).code)[month_index] - - header_names = [_('Sales Order'), _('Before')] + [_to_short_month_name(date) for date in ts_months] + [_('Total'), _('Sold'), _('Remaining')] - - result = [] - for name in header_names: - result.append({ - 'label': name, - 'tooltip': '', - }) - # add tooltip for reminaing - result[-1]['tooltip'] = _('What is still to deliver based on sold hours and hours already done. Equals to sold hours - done hours.') - return result - - def _table_row_default(self): - lenght = len(self._table_header()) - return [0.0] * (lenght - 1) # before, M1, M2, M3, Done, Sold, Remaining - - def _table_rows_sql_query(self): - initial_date = fields.Date.from_string(fields.Date.today()) - ts_months = sorted([fields.Date.to_string(initial_date - relativedelta(months=i, day=1)) for i in range(0, DEFAULT_MONTH_RANGE)]) # M1, M2, M3 - # build query - query = """ - SELECT - 'timesheet' AS type, - date_trunc('month', date)::date AS month_date, - E.id AS employee_id, - S.order_id AS sale_order_id, - A.so_line AS sale_line_id, - SUM(A.unit_amount) AS number_hours - FROM account_analytic_line A - JOIN hr_employee E ON E.id = A.employee_id - LEFT JOIN sale_order_line S ON S.id = A.so_line - WHERE A.project_id IS NOT NULL - AND A.project_id IN %s - AND A.date < %s - GROUP BY date_trunc('month', date)::date, S.order_id, A.so_line, E.id - """ - - last_ts_month = fields.Date.to_string(fields.Date.from_string(ts_months[-1]) + relativedelta(months=1)) - query_params = (tuple(self.ids), last_ts_month) - return query, query_params - - def _table_rows_get_employee_lines(self, data_from_db): - initial_date = fields.Date.today() - ts_months = sorted([initial_date - relativedelta(months=i, day=1) for i in range(0, DEFAULT_MONTH_RANGE)]) # M1, M2, M3 - default_row_vals = self._table_row_default() - - # extract employee names - employee_ids = set() - for data in data_from_db: - employee_ids.add(data['employee_id']) - map_empl_names = {empl.id: empl.name for empl in self.env['hr.employee'].sudo().browse(employee_ids)} - - # extract rows data for employee, sol and so rows - rows_employee = {} # (so, sol, employee) -> [INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted] - for data in data_from_db: - sale_line_id = data['sale_line_id'] - sale_order_id = data['sale_order_id'] - # employee row - row_key = (data['sale_order_id'], sale_line_id, data['employee_id']) - if row_key not in rows_employee: - meta_vals = { - 'label': map_empl_names.get(row_key[2]), - 'sale_line_id': sale_line_id, - 'sale_order_id': sale_order_id, - 'res_id': row_key[2], - 'res_model': 'hr.employee', - 'type': 'hr_employee' - } - rows_employee[row_key] = [meta_vals] + default_row_vals[:] # INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted - - index = False - if data['type'] == 'timesheet': - if data['month_date'] in ts_months: - index = ts_months.index(data['month_date']) + 2 - elif data['month_date'] < ts_months[0]: - index = 1 - rows_employee[row_key][index] += data['number_hours'] - rows_employee[row_key][5] += data['number_hours'] - return rows_employee - - def _table_get_empty_so_lines(self): - """ get the Sale Order Lines having no timesheet but having generated a task or a project """ - so_lines = self.sudo().mapped('sale_line_id.order_id.order_line').filtered(lambda sol: sol.is_service and not sol.is_expense and not sol.is_downpayment) - # include the service SO line of SO sharing the same project - sale_order = self.env['sale.order'].search([('project_id', 'in', self.ids)]) - return set(so_lines.ids) | set(sale_order.mapped('order_line').filtered(lambda sol: sol.is_service and not sol.is_expense).ids), set(so_lines.mapped('order_id').ids) | set(sale_order.ids) - - # -------------------------------------------------- - # Actions: Stat buttons, ... - # -------------------------------------------------- - - def _plan_prepare_actions(self, values): - actions = [] - if len(self) == 1: - task_order_line_ids = [] - # retrieve all the sale order line that we will need later below - if self.env.user.has_group('sales_team.group_sale_salesman') or self.env.user.has_group('sales_team.group_sale_salesman_all_leads'): - task_order_line_ids = self.env['project.task'].read_group([('project_id', '=', self.id), ('sale_line_id', '!=', False)], ['sale_line_id'], ['sale_line_id']) - task_order_line_ids = [ol['sale_line_id'][0] for ol in task_order_line_ids] - - if self.env.user.has_group('sales_team.group_sale_salesman'): - if self.pricing_type != 'task_rate' and self.allow_billable and not self.sale_order_id: - actions.append({ - 'label': _("Create a Sales Order"), - 'type': 'action', - 'action_id': 'sale_timesheet.project_project_action_multi_create_sale_order', - 'context': json.dumps({'active_id': self.id, 'active_model': 'project.project'}), - }) - if self.env.user.has_group('sales_team.group_sale_salesman_all_leads'): - to_invoice_amount = values['dashboard']['profit'].get('to_invoice', False) # plan project only takes services SO line with timesheet into account - - sale_order_ids = self.env['sale.order.line'].read_group([('id', 'in', task_order_line_ids)], ['order_id'], ['order_id']) - sale_order_ids = [s['order_id'][0] for s in sale_order_ids] - sale_order_ids = self.env['sale.order'].search_read([('id', 'in', sale_order_ids), ('invoice_status', '=', 'to invoice')], ['id']) - sale_order_ids = list(map(lambda x: x['id'], sale_order_ids)) - - if to_invoice_amount and sale_order_ids: - if len(sale_order_ids) == 1: - actions.append({ - 'label': _("Create Invoice"), - 'type': 'action', - 'action_id': 'sale.action_view_sale_advance_payment_inv', - 'context': json.dumps({'active_ids': sale_order_ids, 'active_model': 'project.project'}), - }) - else: - actions.append({ - 'label': _("Create Invoice"), - 'type': 'action', - 'action_id': 'sale_timesheet.project_project_action_multi_create_invoice', - 'context': json.dumps({'active_id': self.id, 'active_model': 'project.project'}), - }) - return actions - - def _plan_get_stat_button(self): - stat_buttons = [] - num_projects = len(self) - if num_projects == 1: - action_data = _to_action_data('project.project', res_id=self.id, - views=[[self.env.ref('project.edit_project').id, 'form']]) - else: - action_data = _to_action_data(action=self.env.ref('project.open_view_project_all_config').sudo(), - domain=[('id', 'in', self.ids)]) - - stat_buttons.append({ - 'name': _('Project') if num_projects == 1 else _('Projects'), - 'count': num_projects, - 'icon': 'fa fa-puzzle-piece', - 'action': action_data - }) - - # if only one project, add it in the context as default value - tasks_domain = [('project_id', 'in', self.ids)] - tasks_context = self.env.context.copy() - tasks_context.pop('search_default_name', False) - late_tasks_domain = [('project_id', 'in', self.ids), ('date_deadline', '<', fields.Date.to_string(fields.Date.today())), ('date_end', '=', False)] - overtime_tasks_domain = [('project_id', 'in', self.ids), ('overtime', '>', 0), ('planned_hours', '>', 0)] - - if len(self) == 1: - tasks_context = {**tasks_context, 'default_project_id': self.id} - elif len(self): - task_projects_ids = self.env['project.task'].read_group([('project_id', 'in', self.ids)], ['project_id'], ['project_id']) - task_projects_ids = [p['project_id'][0] for p in task_projects_ids] - if len(task_projects_ids) == 1: - tasks_context = {**tasks_context, 'default_project_id': task_projects_ids[0]} - - stat_buttons.append({ - 'name': _('Tasks'), - 'count': sum(self.mapped('task_count')), - 'icon': 'fa fa-tasks', - 'action': _to_action_data( - action=self.env.ref('project.action_view_task').sudo(), - domain=tasks_domain, - context=tasks_context - ) - }) - stat_buttons.append({ - 'name': [_("Tasks"), _("Late")], - 'count': self.env['project.task'].search_count(late_tasks_domain), - 'icon': 'fa fa-tasks', - 'action': _to_action_data( - action=self.env.ref('project.action_view_task').sudo(), - domain=late_tasks_domain, - context=tasks_context, - ), - }) - stat_buttons.append({ - 'name': [_("Tasks"), _("in Overtime")], - 'count': self.env['project.task'].search_count(overtime_tasks_domain), - 'icon': 'fa fa-tasks', - 'action': _to_action_data( - action=self.env.ref('project.action_view_task').sudo(), - domain=overtime_tasks_domain, - context=tasks_context, - ), - }) - - if self.env.user.has_group('sales_team.group_sale_salesman_all_leads'): - # read all the sale orders linked to the projects' tasks - task_so_ids = self.env['project.task'].search_read([ - ('project_id', 'in', self.ids), ('sale_order_id', '!=', False) - ], ['sale_order_id']) - task_so_ids = [o['sale_order_id'][0] for o in task_so_ids] - - sale_orders = self.mapped('sale_line_id.order_id') | self.env['sale.order'].browse(task_so_ids) - if sale_orders: - so_action = dict( - context={'create': False, 'edit': False, 'delete': False}, - domain=[('id', 'in', sale_orders.ids)], - ) - - if len(sale_orders) == 1: - so_action.update({ - 'action': self.env.ref('sale.action_sale_order_form_view').sudo(), - 'res_id': sale_orders.id, - }) - else: - so_action['action'] = self.env.ref('sale.action_orders').sudo() - - stat_buttons.append({ - 'name': _('Sales Orders'), - 'count': len(sale_orders), - 'icon': 'fa fa-dollar', - 'action': _to_action_data(**so_action), - }) - - invoice_ids = self.env['sale.order'].search_read([('id', 'in', sale_orders.ids)], ['invoice_ids']) - invoice_ids = list(itertools.chain(*[i['invoice_ids'] for i in invoice_ids])) - invoice_ids = self.env['account.move'].search_read([('id', 'in', invoice_ids), ('move_type', '=', 'out_invoice')], ['id']) - invoice_ids = list(map(lambda x: x['id'], invoice_ids)) - - if invoice_ids: - stat_buttons.append({ - 'name': _('Invoices'), - 'count': len(invoice_ids), - 'icon': 'fa fa-pencil-square-o', - 'action': _to_action_data( - action=self.env.ref('account.action_move_out_invoice_type').sudo(), - domain=[('id', 'in', invoice_ids), ('move_type', '=', 'out_invoice')], - context={'create': False, 'delete': False} - ) - }) - - ts_tree = self.env.ref('hr_timesheet.hr_timesheet_line_tree') - ts_form = self.env.ref('hr_timesheet.hr_timesheet_line_form') - if self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day'): - timesheet_label = [_('Days'), _('Recorded')] - else: - timesheet_label = [_('Hours'), _('Recorded')] - - default_project_ctx = {} - if len(self) == 1: - default_project_ctx = {'default_project_id': self.id} - - stat_buttons.append({ - 'name': timesheet_label, - 'count': sum(self.mapped('total_timesheet_time')), - 'icon': 'fa fa-calendar', - 'action': _to_action_data( - 'account.analytic.line', - domain=[('project_id', 'in', self.ids)], - views=[(ts_tree.id, 'list'), (ts_form.id, 'form')], - context=default_project_ctx, - ) - }) - - return stat_buttons - - -def _to_action_data(model=None, *, action=None, views=None, res_id=None, domain=None, context=None): - # pass in either action or (model, views) - if action: - assert model is None and views is None - act = clean_action(action.read()[0], env=action.env) - model = act['res_model'] - views = act['views'] - # FIXME: search-view-id, possibly help? - descr = { - 'data-model': model, - 'data-views': json.dumps(views), - } - if context is not None: # otherwise copy action's? - descr['data-context'] = json.dumps(context) - if res_id: - descr['data-res-id'] = res_id - elif domain: - descr['data-domain'] = json.dumps(domain) - return descr diff --git a/addons/sale_timesheet/models/project_update.py b/addons/sale_timesheet/models/project_update.py index 467167d7c2c..97d95d64a22 100644 --- a/addons/sale_timesheet/models/project_update.py +++ b/addons/sale_timesheet/models/project_update.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from dateutil.relativedelta import relativedelta +from odoo import api, models +from odoo.tools import float_utils, format_amount, formatLang -from odoo import api, fields, models -from odoo.tools import float_utils, format_amount, formatLang, format_date, DEFAULT_SERVER_DATE_FORMAT +from collections import defaultdict class ProjectUpdate(models.Model): _inherit = 'project.update' @@ -13,55 +13,66 @@ class ProjectUpdate(models.Model): def _get_template_values(self, project): return { **super(ProjectUpdate, self)._get_template_values(project), + 'services': self._get_services_values(project), 'profitability': self._get_profitability_values(project), } + @api.model + def _get_project_sols(self, project): + return self.env['sale.order.line'].search([('order_id', '=', project.sale_order_id.id), ('is_service', '=', True)]) + + @api.model + def _get_services_values(self, project): + if not project.allow_billable: + return {} + services = [] + total_sold_per_uom = defaultdict(float) + total_effective_per_uom = defaultdict(float) + total_sold, total_effective, total_remaining = 0, 0, 0 + sols = self._get_project_sols(project) + for sol in sols: + #We only want to consider hours and days for this calculation + if sol.product_uom.category_id == self.env.company.timesheet_encode_uom_id.category_id: + services.append({ + 'name': sol.name, + 'uom': sol.product_uom.name, + 'sold_value': sol.product_uom_qty, + 'effective_value': sol.qty_delivered, + 'remaining_value': sol.product_uom_qty - sol.qty_delivered, + 'sol': sol, + }) + total_sold_per_uom[sol.product_uom] += sol.product_uom_qty + total_effective_per_uom[sol.product_uom] += sol.qty_delivered + total_sold = sum( + uom._compute_quantity(sol.product_uom_qty, self.env.company.timesheet_encode_uom_id, raise_if_failure=False) + for uom, quantity in total_sold_per_uom.items() + ) + total_effective = sum( + uom._compute_quantity(sol.product_uom_qty, self.env.company.timesheet_encode_uom_id, raise_if_failure=False) + for uom, quantity in total_effective_per_uom.items() + ) + total_remaining = total_sold - total_effective + return { + 'data': services, + 'total_sold': total_sold, + 'total_effective': total_effective, + 'total_remaining': total_remaining, + } + @api.model def _get_profitability_values(self, project): 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 {} - 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, + profitability = project._get_profitability_common() + return { 'analytic_account_id': project.analytic_account_id, - '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': '', 'previous_timesheet_unit_amount': '', - 'timesheet_trend': '', 'costs': '', 'revenues': '', 'margin': '', - 'margin_formatted': '', 'margin_percentage': '', 'billing_rate': '', + '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), } - 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/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index 166b96296de..c795ca2956b 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -94,15 +94,6 @@ class SaleOrder(models.Model): ) >= 0 ) - def action_view_project_ids(self): - self.ensure_one() - # redirect to form or kanban view - if len(self.project_ids) == 1 and self.project_ids.project_overview and self.env.user.has_group('project.group_project_manager'): - action = self.project_ids.action_view_timesheet_plan() - else: - action = super().action_view_project_ids() - return action - def action_view_timesheet(self): self.ensure_one() action = self.env["ir.actions.actions"]._for_xml_id("sale_timesheet.timesheet_action_from_sales_order") diff --git a/addons/sale_timesheet/report/project_profitability_report_analysis_views.xml b/addons/sale_timesheet/report/project_profitability_report_analysis_views.xml index 80bcc95ac4d..660986b2ef9 100644 --- a/addons/sale_timesheet/report/project_profitability_report_analysis_views.xml +++ b/addons/sale_timesheet/report/project_profitability_report_analysis_views.xml @@ -79,31 +79,4 @@ - - Project Costs and Revenues - project.profitability.report - pivot,graph - - { - 'pivot_row_groupby': ['line_date:month'], - 'group_by_no_leaf':1, - 'graph_groupbys': ['project_id'], - 'group_by':[], - 'sale_show_order_product_name': 1, - } - -

- No data yet! -

- Analyze the profitability of your projects by comparing their costs and revenues. -

-
-
- - - diff --git a/addons/sale_timesheet/static/src/js/project_overview.js b/addons/sale_timesheet/static/src/js/project_overview.js deleted file mode 100644 index 6f40b30d55d..00000000000 --- a/addons/sale_timesheet/static/src/js/project_overview.js +++ /dev/null @@ -1,56 +0,0 @@ -odoo.define('sale_timesheet.project_overview', function (require) { - "use strict"; - - var qweb = require('web.qweb'); - var viewRegistry = require('web.view_registry'); - - const Controller = qweb.Controller.extend({ - events: _.extend({}, qweb.Controller.prototype.events, { - 'click .project_overview_foldable': '_onFoldToggle', - }), - - _onFoldToggle(ev) { - const {model, resId} = ev.target.dataset; - const shouldOpen = ev.target.classList.contains('fa-caret-right'); - if (model === 'sale.order' && shouldOpen) { - this._openSaleOrder(resId); - } - else if (model === 'sale.order.line' && shouldOpen) { - this._openSaleOrderLine(resId); - } - else { - const targetClass = `.${model.replace(/\./g, '_')}_${resId || 'None'}`; - this.$(targetClass).hide(); - } - $(ev.target).toggleClass('fa-caret-right'); - $(ev.target).toggleClass('fa-caret-down'); - }, - - _openSaleOrder(id) { - id = id || 'None'; - const targetClass = `.sale_order_${id}`; - this.$(`.o_timesheet_forecast_sale_order_line${targetClass}`).show(); - this.$(`.o_timesheet_forecast_hr_employee${targetClass}`).hide(); - this.$(`.o_timesheet_forecast_sale_order_line${targetClass} .fa`).removeClass('fa-caret-down'); - this.$(`.o_timesheet_forecast_sale_order_line${targetClass} .fa`).addClass('fa-caret-right'); - - }, - - _openSaleOrderLine(id) { - id = id || 'None'; - const targetClass = `.sale_order_line_${id}`; - this.$(`.o_timesheet_forecast_hr_employee${targetClass}`).show(); - }, - }); - - var ProjectOverview = qweb.View.extend({ - withSearchBar: true, - searchMenuTypes: ['filter', 'favorite'], - - config: _.extend({}, qweb.View.prototype.config, { - Controller: Controller, - }), - }); - - viewRegistry.add('project_overview', ProjectOverview); -}); diff --git a/addons/sale_timesheet/static/src/scss/sale_timesheet.scss b/addons/sale_timesheet/static/src/scss/sale_timesheet.scss deleted file mode 100644 index 3ee321c7fa9..00000000000 --- a/addons/sale_timesheet/static/src/scss/sale_timesheet.scss +++ /dev/null @@ -1,144 +0,0 @@ - -.o_timesheet_plan_sale_timesheet { - - .o_title { - margin-top: 30px; - margin-bottom: 30px; - } - - [type='action'] { - color: $o-brand-primary; - cursor: pointer; - } - - .o_timesheet_plan_stat_buttons { - text-align: right; - float: none; - width: auto; - box-shadow: unset; - margin-left: -$o-horizontal-padding - 1; - @include media-breakpoint-between(lg, xl, $o-extra-grid-breakpoints) { - margin-left: (-$o-horizontal-padding*2) - 1; - } - > .btn.oe_stat_button { - border-bottom: 1px solid #ced4da; - } - } - - .o_profitability_wrapper { - display: flex; - flex: 1; - flex-wrap: wrap; - - .o_profitability_section { - display: flex; - flex: 1; - justify-content: flex-start; - } - - } - - .o_timesheet_plan_sale_timesheet_dashboard { - - .table { - margin-top: 15px; - a { - cursor: pointer; - } - } - .table > thead > tr > th, .table > thead > tr > td, - .table > tbody > tr > th, .table > tbody > tr > td { - border: none; - } - .table > tbody > tr > td.o_timesheet_plan_dashboard_cell { - text-align: right; - } - .table > tbody > tr > td.o_timesheet_plan_dashboard_total { - border-top: 1px solid; - text-align: right; - } - - .section { - - } - - } - - .o_timesheet_plan_sale_timesheet_people_time { - $-timesheet-progress-colors: ( - 'billable_time': nth($o-colors-complete, 3), - 'billable_fixed': nth($o-colors-complete, 5), - 'non_billable_project': theme-color('primary'), - 'non_billable_timesheet': nth($o-colors-complete, 11), - 'non_billable': nth($o-colors-complete, 18), - 'canceled': $o-gray-500, - ); - - @each $-key, $-bg in $-timesheet-progress-colors { - .o_progress_#{$-key} { - background-color: $-bg; - - a { - color: color-yiq($-bg); - } - } - } - - .table > thead > tr > td, - .table > tbody > tr > th, .table > tbody > tr > td { - border: none; - padding: 0px; - } - - .table > thead > tr > th { - border: none; - padding-left: 0px;; - } - } - - .o_project_plan_project_timesheet_forecast { - - margin-top: 50px; - - th{ - text-align: center; - vertical-align: top; - } - th.o_right_bordered, td.o_right_bordered { - border-right: 2px solid $o-brand-primary; - text-align: center; - } - - .o_timesheet_forecast_sale_order{ - font-weight: bold; - & > td:first-child { - color: $o-brand-primary; - } - } - .o_timesheet_forecast_sale_order_line{ - td:first-child { - padding-left: 15px; - } - td:first-child > a { - margin-left: 15px; - cursor: pointer; - } - } - .o_timesheet_forecast_hr_employee{ - td:first-child { - padding-left: 30px; - } - } - - .o_timesheet_plan_redirect { - cursor: pointer; - } - - .o_canceled_tag { - background-color: $o-gray-500; - color: color-yiq($o-gray-500); - border: 0px; - } - } - -} 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 2dd4cfa099b..5cc7f846b14 100644 --- a/addons/sale_timesheet/static/src/xml/sale_project_templates.xml +++ b/addons/sale_timesheet/static/src/xml/sale_project_templates.xml @@ -6,31 +6,73 @@ - + + + - - - -
+ + + +
- - Profitability - - - Recorded Hours - + Sold +
+
+ +
+
+ + +
+
+
+
+
+ + + + +
+
+
+ Total Sold +
+ +
+
+
+ Effective + +
+
+ Planned + +
+
+ Remaining + +
+
+
+
+
+ + +<<<<<<< HEAD + +======= + +>>>>>>> [IMP] sale_timesheet: remove overview and improve updates + +
+
+
+ Profitability
- - - See Overview - -
diff --git a/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js b/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js index a51a39d81f4..33ecfdeb765 100644 --- a/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js +++ b/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js @@ -247,29 +247,21 @@ tour.register('sale_timesheet_tour', { trigger: ".o_project_updates_breadcrumb", content: 'Open Updates' }, { - trigger: ".o_rightpanel_button a:contains('See Overview')", - content: 'See Overview' + trigger: ".o_rightpanel_title:eq(0):contains('Sold')", + content: 'Check the user sees Sold section' }, { - trigger: ".breadcrumb :contains('Overview')", - content: 'Check the user sees Overview' -}, { - trigger: ".o_back_button" -},{ - trigger: ".o_rightpanel_left_text:eq(0):contains('Timesheets')", - content: 'Check the user sees Timesheet stat', + trigger: ".o_rightpanel_left_text:eq(0):contains('Prepaid Hours')", + content: 'Check the user sees Sold subsection', run: function () {}, +}, { + trigger: ".o_rightpanel_title:eq(1):contains('Total Sold')", + content: 'Check the user sees Total Sold section' }, { - trigger: ".o_rightpanel_left_text:eq(1):contains('Revenues')", - content: 'Check the user sees Revenues stat', - run: function () {}, + trigger: ".o_rightpanel_title:eq(2):contains('Profitability')", + content: 'Check the user sees Profitability section' }, { - trigger: ".o_rightpanel_left_text:eq(2):contains('Costs')", - content: 'Check the user sees Cost stat', - run: function () {}, -}, { - trigger: ".o_rightpanel_left_text:eq(3):contains('Margin')", - content: 'Check the user sees Margin stat', - run: function () {}, + trigger: ".o_rightpanel_title:eq(3):contains('Milestones')", + content: 'Check the user sees Milestones section' }, { trigger: ".o-kanban-button-new" }, { @@ -277,22 +269,6 @@ tour.register('sale_timesheet_tour', { run: 'text New update' }, { trigger: ".o_form_button_save" -}, { - trigger: ".o_field_widget[name=description] :contains('Profitability')", - content: 'Check the user sees Profitability section', - run: function () {}, -}, { - trigger: ".o_field_widget[name=description] :contains('billing rate')", - content: 'Check the user sees Profitability section content', - run: function () {}, -}, { - trigger: ".o_field_widget[name=description] :contains('People')", - content: 'Check the user sees People section', - run: function () {}, -}, { - trigger: ".o_field_widget[name=description] :contains('(new this month)')", - content: 'Check the user sees People section content', - run: function () {}, }]); }); diff --git a/addons/sale_timesheet/tests/__init__.py b/addons/sale_timesheet/tests/__init__.py index 13e3de402e5..9206f5f0eab 100644 --- a/addons/sale_timesheet/tests/__init__.py +++ b/addons/sale_timesheet/tests/__init__.py @@ -7,7 +7,6 @@ from . import test_sale_service from . import test_project_billing from . import test_reinvoice from . import test_reporting -from . import test_project_overview from . import test_project_billing_multicompany from . import test_upsell_warning from . import test_edit_so_line_timesheet diff --git a/addons/sale_timesheet/tests/test_project_overview.py b/addons/sale_timesheet/tests/test_project_overview.py deleted file mode 100644 index 90f51e1d0e8..00000000000 --- a/addons/sale_timesheet/tests/test_project_overview.py +++ /dev/null @@ -1,130 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo.addons.sale_timesheet.tests.test_reporting import TestReporting -from odoo.tools import float_compare -from odoo.tests import tagged - - -@tagged('-at_install', 'post_install') -class TestSaleProject(TestReporting): - - def test_project_overview_by_project(self): - rounding = self.env.company.currency_id.rounding - - so_line_deliver_global_project = self.env['sale.order.line'].create({ - 'name': self.product_delivery_timesheet2.name, - 'product_id': self.product_delivery_timesheet2.id, - 'product_uom_qty': 50, - 'product_uom': self.product_delivery_timesheet2.uom_id.id, - 'price_unit': self.product_delivery_timesheet2.list_price, - 'order_id': self.sale_order_2.id, - }) - so_line_deliver_no_task = self.env['sale.order.line'].create({ - 'name': self.product_delivery_manual1.name, - 'product_id': self.product_delivery_manual1.id, - 'product_uom_qty': 50, - 'product_uom': self.product_delivery_manual1.uom_id.id, - 'price_unit': self.product_delivery_manual1.list_price, - 'order_id': self.sale_order_2.id, - }) - so_line_deliver_no_task.write({'qty_delivered': 1.0}) - - self.sale_order_2.action_confirm() - project_so = self.so_line_order_project.project_id - # log timesheet for billable time - timesheet1 = self._log_timesheet_manager(project_so, 10, so_line_deliver_global_project.task_id) - - task_so = self.so_line_order_project.task_id - # logged some timesheets: on project only, then on tasks with different employees - timesheet2 = self._log_timesheet_user(project_so, 2) - timesheet3 = self._log_timesheet_user(project_so, 3, task_so) - timesheet4 = self._log_timesheet_manager(project_so, 1, task_so) - - # create a task which is not linked to sales order and fill non-billable timesheet - task = self.env['project.task'].create({ - 'name': 'Task', - 'project_id': project_so.id, - 'allow_billable': False, - 'sale_line_id': False - }) - timesheet5 = self._log_timesheet_user(project_so, 5, task) - - # invoice the Sales Order SO2 - context = { - "active_model": 'sale.order', - "active_ids": [self.sale_order_2.id], - "active_id": self.sale_order_2.id, - 'open_invoices': True, - } - - payment = self.env['sale.advance.payment.inv'].create({ - 'advance_payment_method': 'delivered', - }) - - action_invoice = payment.with_context(context).create_invoices() - invoice = self.env['account.move'].browse(action_invoice['res_id']) - invoice.action_post() - - # simulate the auto creation of the SO line for expense, like we confirm a vendor bill. - so_line_expense = self.env['sale.order.line'].create({ - 'name': self.product_expense.name, - 'product_id': self.product_expense.id, - 'product_uom_qty': 0.0, - 'product_uom': self.product_expense.uom_id.id, - 'price_unit': self.product_expense.list_price, # reinvoice at sales price - 'order_id': self.sale_order_2.id, - 'is_expense': True, - }) - - expense = self.env['account.analytic.line'].create({ - 'name': 'expense on project_so', - 'account_id': project_so.analytic_account_id.id, - 'so_line': so_line_expense.id, - 'employee_id': self.employee_user.id, - 'unit_amount': 4, - 'amount': 4 * self.product_expense.list_price * -1, - 'product_id': self.product_expense.id, - 'product_uom_id': self.product_expense.uom_id.id, - }) - - other_revenues = self.env['account.analytic.line'].create({ - 'name': 'pther revenues on project_so', - 'account_id': project_so.analytic_account_id.id, - 'employee_id': self.employee_user.id, - 'unit_amount': 1, - 'amount': self.product_expense.list_price, - 'product_id': self.product_expense.id, - 'product_uom_id': self.product_expense.uom_id.id, - }) - - view_id = self.env.ref('sale_timesheet.project_timesheet_action_client_timesheet_plan').id - vals = self.env['project.project']._qweb_prepare_qcontext(view_id, [['id', '=', project_so.id]]) - - dashboard_value = timesheet2.unit_amount + timesheet3.unit_amount + timesheet4.unit_amount + timesheet5.unit_amount + timesheet1.unit_amount - project_so_timesheet_sold_unit = timesheet3.unit_amount + timesheet4.unit_amount - project_rate_non_billable = timesheet5.unit_amount / dashboard_value * 100 - project_rate_non_billable_project = timesheet2.unit_amount / dashboard_value * 100 - project_rate_billable_time = timesheet1.unit_amount / dashboard_value * 100 - project_rate_billable_fixed = project_so_timesheet_sold_unit / dashboard_value * 100 - project_rate_total = project_rate_non_billable + project_rate_non_billable_project + project_rate_billable_time + project_rate_billable_fixed - project_invoiced = self.so_line_order_project.price_unit * self.so_line_order_project.product_uom_qty * timesheet1.unit_amount - project_timesheet_cost = timesheet2.amount + timesheet3.amount + timesheet4.amount + timesheet5.amount + timesheet1.amount - project_other_revenues = invoice.invoice_line_ids.search([('product_id', '=', self.product_delivery_manual1.id)]) - - self.assertEqual(float_compare(vals['dashboard']['time']['non_billable'], timesheet5.unit_amount, precision_rounding=rounding), 0, "The hours non-billable should be the one from the SO2 line, as we are in ordered quantity") - self.assertEqual(float_compare(vals['dashboard']['time']['non_billable_project'], timesheet2.unit_amount, precision_rounding=rounding), 0, "The hours non-billable-project should be the one from the SO2 line, as we are in ordered quantity") - self.assertEqual(float_compare(vals['dashboard']['time']['billable_time'], timesheet1.unit_amount, precision_rounding=rounding), 0, "The hours billable-time should be the one from the SO2 line, as we are in ordered quantity") - self.assertEqual(float_compare(vals['dashboard']['time']['billable_fixed'], project_so_timesheet_sold_unit, precision_rounding=rounding), 0, "The hours billable-fixed should be the one from the SO2 line, as we are in ordered quantity") - self.assertEqual(float_compare(vals['dashboard']['time']['total'], dashboard_value, precision_rounding=rounding), 0, "The total hours should be the one from the SO2 line, as we are in ordered quantity") - self.assertEqual(float_compare(vals['dashboard']['rates']['non_billable'], project_rate_non_billable, precision_rounding=rounding), 0, "The rate non-billable should be the one from the SO2 line, as we are in ordered quantity") - self.assertEqual(float_compare(vals['dashboard']['rates']['non_billable_project'], project_rate_non_billable_project, precision_rounding=rounding), 0, "The rate non-billable-project should be the one from the SO2 line, as we are in ordered quantity") - self.assertEqual(float_compare(vals['dashboard']['rates']['billable_time'], project_rate_billable_time, precision_rounding=rounding), 0, "The rate billable-time should be the one from the SO2 line, as we are in ordered quantity") - self.assertEqual(float_compare(vals['dashboard']['rates']['billable_fixed'], project_rate_billable_fixed, precision_rounding=rounding), 0, "The rate billable-fixed should be the one from the SO2 line, as we are in ordered quantity") - self.assertEqual(float_compare(vals['dashboard']['rates']['total'], project_rate_total, precision_rounding=rounding), 0, "The total rates should be the one from the SO2 line, as we are in ordered quantity") - self.assertEqual(float_compare(vals['dashboard']['profit']['invoiced'], project_invoiced, precision_rounding=rounding), 0, "The amount invoiced should be the one from the SO2 line, as we are in ordered quantity") - self.assertEqual(float_compare(vals['dashboard']['profit']['cost'], project_timesheet_cost, precision_rounding=rounding), 0, "The amount cost should be the one from the SO2 line, as we are in ordered quantity") - self.assertEqual(float_compare(vals['dashboard']['profit']['expense_cost'], expense.amount, precision_rounding=rounding), 0, "The amount expense-cost should be the one from the SO2 line, as we are in ordered quantity") - self.assertEqual(float_compare(vals['dashboard']['profit']['other_revenues'], other_revenues.amount + project_other_revenues.price_total, precision_rounding=rounding), 0, "The amount of the other revenues should be equal to the corresponding account move line and the one from the SO line") - self.assertEqual(float_compare(vals['dashboard']['profit']['total'], project_invoiced + project_timesheet_cost + expense.amount + other_revenues.amount + project_other_revenues.price_total, precision_rounding=rounding), 0, "The total amount should be the sum of the SO2 line and the created other_revenues account analytic line") - self.assertEqual(float_compare(vals['repartition_employee_max'], 11.0, precision_rounding=rounding), 0, "The amount of repartition-employee-max should be the one from SO2 line") diff --git a/addons/sale_timesheet/tests/test_project_update.py b/addons/sale_timesheet/tests/test_project_update.py index de79a04f397..4ed4759fc9a 100644 --- a/addons/sale_timesheet/tests/test_project_update.py +++ b/addons/sale_timesheet/tests/test_project_update.py @@ -1,124 +1,31 @@ # -*- 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 @tagged('-at_install', 'post_install') class TestProjectUpdateSaleTimesheet(TestProjectUpdate): - def test_project_update_form_profitability(self): - try: - with Form(self.env['project.update'].with_context({'default_project_id': self.project_pigs.id})) as update_form: - update_form.name = "Test" - update_form.progress = 65 - update = update_form.save() - except Exception as e: - raise AssertionError("Error raised unexpectedly while filling the project update form ! Exception : " + e.args[0]) - - self.assertTrue("Profitability" in update.description, "The description should contain 'Profitability'.") - def test_project_update_description_profitability(self): 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'], 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'], 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'") - self.assertEqual(template_values['profitability']['timesheet_unit_amount'], '0', "Timesheet unit amount used in the template should be defined.") - self.assertEqual(template_values['profitability']['previous_timesheet_unit_amount'], '0', "Previous Timesheet unit amount used in the template should be defined.") - self.assertEqual(template_values['profitability']['timesheet_trend'], "0", "Timesheet trend used in the template should be well defined.") self.assertEqual(template_values['profitability']['costs'], "$\xa00.00", "Project costs used in the template should be well defined") self.assertEqual(template_values['profitability']['revenues'], "$\xa00.00", "Project revenues used in the template should be well defined") self.assertEqual(template_values['profitability']['margin'], 0, "Margin used in the template should be well defined") self.assertEqual(template_values['profitability']['margin_formatted'], "$\xa00.00", "Margin formatted used in the template should be well defined") self.assertEqual(template_values['profitability']['margin_percentage'], "0", "Margin percentage used in the template should be well defined") - self.assertEqual(template_values['profitability']['billing_rate'], "0", "Billing rate used in the template should be well defined") def test_project_update_panel_profitability_no_billable(self): panel_data = self.project_pigs.get_panel_data() - self.assertEqual(len(panel_data['profitability_items']['data']), 1, "Panel data should contain 'profitability_items>data' entries") + self.assertEqual(panel_data['profitability_items']['allow_billable'], False, "Project should not be billable by default") try: 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_panel_profitability_no_timesheet(self): - self.project_pigs.allow_billable = True - panel_data = self.project_pigs.get_panel_data() - - self.assertEqual(len(panel_data['profitability_items']['data']), 4, "Panel data should contain 'profitability_items>data' entries") - self.assertEqual(panel_data['profitability_items']['action'], "action_view_timesheet", "Panel data should contain 'profitability_items>action' entry") - try: 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/tests/test_sale_timesheet.py b/addons/sale_timesheet/tests/test_sale_timesheet.py index ca1a5e0bcec..e64c79eb97e 100644 --- a/addons/sale_timesheet/tests/test_sale_timesheet.py +++ b/addons/sale_timesheet/tests/test_sale_timesheet.py @@ -95,7 +95,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet): 'employee_id': self.employee_user.id, }) self.assertEqual(so_line_ordered_project_only.qty_delivered, 0.0, 'Timesheet directly on project does not increase delivered quantity on so line') - self.assertEqual(timesheet3.timesheet_invoice_type, 'non_billable_project', "Timesheets without task shoulbe be 'no project found'") + self.assertEqual(timesheet3.timesheet_invoice_type, 'non_billable', "Timesheets without SO should be be 'non-billable'") self.assertFalse(timesheet3.timesheet_invoice_id, "The timesheet should not be linked to the invoice, since we are in ordered quantity") # log timesheet on task in global project (higher than the initial ordrered qty) @@ -270,7 +270,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet): }) self.assertTrue(float_is_zero(so_line_deliver_only_project.qty_delivered, precision_digits=2), "Timesheeting on project should not incremented the delivered quantity on the SO line") self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should have quantity to invoice') - self.assertEqual(timesheet3.timesheet_invoice_type, 'non_billable_project', "Timesheets without task shoulbe be 'no project found'") + self.assertEqual(timesheet3.timesheet_invoice_type, 'billable_time', "Timesheets with an amount > 0 should be 'billable time'") self.assertFalse(timesheet3.timesheet_invoice_id, "The timesheet3 should not be linked to the invoice yet") # let's log some timesheets on the task (new task/new project) @@ -357,7 +357,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet): self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" should not need to be invoiced on so confirmation') self.assertEqual(timesheet1.timesheet_invoice_type, 'billable_fixed', "Timesheets linked to SO line with ordered product shoulbe be billable fixed since it is a milestone") - self.assertEqual(timesheet2.timesheet_invoice_type, 'non_billable_project', "Timesheets without task shoulbe be 'no project found'") + self.assertEqual(timesheet2.timesheet_invoice_type, 'non_billable', "Timesheets without SO should be be 'non-billable'") self.assertFalse(timesheet1.timesheet_invoice_id, "The timesheet1 should not be linked to the invoice") self.assertFalse(timesheet2.timesheet_invoice_id, "The timesheet2 should not be linked to the invoice") diff --git a/addons/sale_timesheet/views/hr_timesheet_templates.xml b/addons/sale_timesheet/views/hr_timesheet_templates.xml deleted file mode 100644 index 400711b854e..00000000000 --- a/addons/sale_timesheet/views/hr_timesheet_templates.xml +++ /dev/null @@ -1,486 +0,0 @@ - - - - - Timesheet Plan - qweb - project.project - - - -
-
-
-
-
- - - -
-

Recorded Days and Profitability

-

Recorded Hours and Profitability

-
- - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - Days recorded - Hours recorded - -
- - - - ( %) - - Billed on Timesheets -
- - - - ( %) - - Billed at a Fixed price -
- - - - ( %) - - No task found -
- - - - ( %) - - - - Non Billable Tasks - - -
- - - - ( %) - - - - Non Billable Timesheets - - -
- - - - ( %) - - Cancelled -
- - - - - Total
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Profitability -
- - - Invoiced -
- - - To invoice -
- - Other Revenues
- - - Timesheet costs -
- - - Other costs -
- - - Re-invoiced costs -
- - - - Total
-
-
-
-
- -
-

Time by people

-
- -
- -

There are no timesheets for now.

-
- -
- - - - - - - - - - - - - - - - - - - - - -
EmployeeDays SpentHours Spent - -
- - - - - - - - - -
-
- - - - Billed on Timesheets - billable_time - - - Billed at a Fixed price - billable_fixed - - - No task found - non_billable_project - - - Non billable timesheets - non_billable_timesheet - - - Non billable tasks - non_billable - - - Cancelled - canceled - -
-
-
-
-
-
- -
-

Timesheets

-
- - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

Timesheets

- -
- - - - - - - - - - - - - Cancelled - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- -
-
-
-
-
-
-
- - - -
diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index 159a4514528..b5ba5e96fcd 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -6,12 +6,23 @@ project.project - - +