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 - - +