diff --git a/addons/hr_timesheet/models/project.py b/addons/hr_timesheet/models/project.py index ade6fc66b6b..d33d9c45722 100644 --- a/addons/hr_timesheet/models/project.py +++ b/addons/hr_timesheet/models/project.py @@ -88,6 +88,7 @@ class Task(models.Model): effective_hours = fields.Float("Hours Spent", compute='_compute_effective_hours', compute_sudo=True, store=True, help="Computed using the sum of the task work done.") total_hours_spent = fields.Float("Total Hours", compute='_compute_total_hours_spent', store=True, help="Computed as: Time Spent + Sub-tasks Hours.") progress = fields.Float("Progress", compute='_compute_progress_hours', store=True, group_operator="avg", help="Display progress of current task.") + overtime = fields.Float(compute='_compute_progress_hours', store=True) subtask_effective_hours = fields.Float("Sub-tasks Hours Spent", compute='_compute_subtask_effective_hours', store=True, help="Sum of actually spent hours on the subtask(s)") timesheet_ids = fields.One2many('account.analytic.line', 'task_id', 'Timesheets') @@ -108,12 +109,14 @@ class Task(models.Model): for task in self: if (task.planned_hours > 0.0): task_total_hours = task.effective_hours + task.subtask_effective_hours + task.overtime = max(task_total_hours - task.planned_hours, 0) if task_total_hours > task.planned_hours: task.progress = 100 else: task.progress = round(100.0 * task_total_hours / task.planned_hours, 2) else: task.progress = 0.0 + task.overtime = 0 @api.depends('effective_hours', 'subtask_effective_hours', 'planned_hours') def _compute_remaining_hours(self): diff --git a/addons/hr_timesheet/views/project_views.xml b/addons/hr_timesheet/views/project_views.xml index 39e156f157e..3df4ad3b4b0 100644 --- a/addons/hr_timesheet/views/project_views.xml +++ b/addons/hr_timesheet/views/project_views.xml @@ -281,6 +281,17 @@ + + project.task.view.search.inherit.sale.timesheet.enterprise + project.task + + + + + + + + project.task.kanban.timer diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index 47573ae7024..eb7cd35a138 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -28,6 +28,7 @@ + diff --git a/addons/sale_timesheet/models/project_overview.py b/addons/sale_timesheet/models/project_overview.py index 946990be91e..e5772610e59 100644 --- a/addons/sale_timesheet/models/project_overview.py +++ b/addons/sale_timesheet/models/project_overview.py @@ -5,6 +5,7 @@ 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 @@ -76,6 +77,11 @@ class Project(models.Model): # 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) + other_revenues = self.env['account.analytic.line'].read_group([ + ('account_id', 'in', self.analytic_account_id.ids), + ('amount', '>=', 0), + ('project_id', '=', False)], ['amount'], [])[0].get('amount', 0) + # profitability, using profitability SQL report profit = dict.fromkeys(['invoiced', 'to_invoice', 'cost', 'expense_cost', 'expense_amount_untaxed_invoiced', 'total'], 0.0) profitability_raw_data = self.env['project.profitability.report'].read_group([('project_id', 'in', self.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_invoiced'], ['project_id']) @@ -85,6 +91,7 @@ class Project(models.Model): profit['cost'] += data.get('timesheet_cost', 0.0) profit['expense_cost'] += data.get('expense_cost', 0.0) profit['expense_amount_untaxed_invoiced'] += data.get('expense_amount_untaxed_invoiced', 0.0) + profit['other_revenues'] = other_revenues or 0 profit['total'] = sum([profit[item] for item in profit.keys()]) dashboard_values['profit'] = profit @@ -103,7 +110,8 @@ class Project(models.Model): employee_ids.extend(list(map(lambda x: x['employee_id'][0], aal_employee_ids))) # Retrieve the employees for which the current user can see theirs timesheets - employees = self.env['hr.employee'].sudo().browse(employee_ids).filtered_domain(self.env['account.analytic.line']._domain_employee_id()) + 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) @@ -253,7 +261,7 @@ class Project(models.Model): 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 = [_('Name'), _('Before')] + [_to_short_month_name(date) for date in ts_months] + [_('Total'), _('Sold'), _('Remaining')] + header_names = [_('Sales Order'), _('Before')] + [_to_short_month_name(date) for date in ts_months] + [_('Total'), _('Sold'), _('Remaining')] result = [] for name in header_names: @@ -411,6 +419,8 @@ class Project(models.Model): # if only one project, add it in the context as default value tasks_domain = [('project_id', 'in', self.ids)] tasks_context = self.env.context + 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)] # filter out all the projects that have no tasks task_projects_ids = self.env['project.task'].read_group([('project_id', 'in', self.ids)], ['project_id'], ['project_id']) @@ -428,6 +438,26 @@ class Project(models.Model): context=tasks_context ) }) + stat_buttons.append({ + 'name': _("Late Tasks"), + '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'), + 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'), + 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 diff --git a/addons/sale_timesheet/report/project_profitability_report_analysis.py b/addons/sale_timesheet/report/project_profitability_report_analysis.py index a8c8fa510d7..ef884e55421 100644 --- a/addons/sale_timesheet/report/project_profitability_report_analysis.py +++ b/addons/sale_timesheet/report/project_profitability_report_analysis.py @@ -31,6 +31,8 @@ class ProfitabilityAnalysis(models.Model): amount_untaxed_invoiced = fields.Float("Untaxed Amount Invoiced", digits=(16, 2), readonly=True, group_operator="sum") expense_amount_untaxed_to_invoice = fields.Float("Untaxed Amount to Re-invoice", digits=(16, 2), readonly=True, group_operator="sum") expense_amount_untaxed_invoiced = fields.Float("Untaxed Re-invoiced Amount", digits=(16, 2), readonly=True, group_operator="sum") + other_revenues = fields.Float("Other Revenues", digits=(16, 2), readonly=True, group_operator="sum", + help="All revenues that are not from timesheets and that are linked to the analytic account of the project.") def init(self): tools.drop_view_if_exists(self._cr, self._table) @@ -73,7 +75,8 @@ class ProfitabilityAnalysis(models.Model): END AS amount_untaxed_invoiced, COST_SUMMARY.timesheet_unit_amount AS timesheet_unit_amount, COST_SUMMARY.timesheet_cost AS timesheet_cost, - COST_SUMMARY.expense_cost AS expense_cost + COST_SUMMARY.expense_cost AS expense_cost, + COST_SUMMARY.other_revenues AS other_revenues FROM project_project P JOIN res_company C ON C.id = P.company_id LEFT JOIN ( @@ -84,6 +87,7 @@ class ProfitabilityAnalysis(models.Model): SUM(timesheet_unit_amount) AS timesheet_unit_amount, SUM(timesheet_cost) AS timesheet_cost, SUM(expense_cost) AS expense_cost, + SUM(other_revenues) AS other_revenues, SUM(downpayment_invoiced) AS downpayment_invoiced FROM ( SELECT @@ -92,6 +96,7 @@ class ProfitabilityAnalysis(models.Model): TS.so_line AS sale_line_id, SUM(TS.unit_amount) AS timesheet_unit_amount, SUM(TS.amount) AS timesheet_cost, + 0.0 AS other_revenues, 0.0 AS expense_cost, 0.0 AS downpayment_invoiced FROM account_analytic_line TS, project_project P @@ -106,6 +111,24 @@ class ProfitabilityAnalysis(models.Model): AAL.so_line AS sale_line_id, 0.0 AS timesheet_unit_amount, 0.0 AS timesheet_cost, + SUM(AAL.amount) AS other_revenues, + 0.0 AS expense_cost, + 0.0 AS downpayment_invoiced + FROM project_project P + LEFT JOIN account_analytic_account AA ON P.analytic_account_id = AA.id + LEFT JOIN account_analytic_line AAL ON AAL.account_id = AA.id + WHERE AAL.amount > 0.0 AND AAL.project_id IS NULL AND P.active = 't' AND P.allow_timesheets = 't' + GROUP BY P.id, AA.id, AAL.so_line + + UNION + + SELECT + P.id AS project_id, + P.analytic_account_id AS analytic_account_id, + AAL.so_line AS sale_line_id, + 0.0 AS timesheet_unit_amount, + 0.0 AS timesheet_cost, + 0.0 AS other_revenues, SUM(AAL.amount) AS expense_cost, 0.0 AS downpayment_invoiced FROM project_project P @@ -122,6 +145,7 @@ class ProfitabilityAnalysis(models.Model): MY_SOLS.id AS sale_line_id, 0.0 AS timesheet_unit_amount, 0.0 AS timesheet_cost, + 0.0 AS other_revenues, 0.0 AS expense_cost, CASE WHEN MY_SOLS.invoice_status = 'invoiced' THEN MY_SOLS.price_reduce ELSE 0.0 END AS downpayment_invoiced FROM project_project P @@ -139,6 +163,7 @@ class ProfitabilityAnalysis(models.Model): OLIS.id AS sale_line_id, 0.0 AS timesheet_unit_amount, 0.0 AS timesheet_cost, + 0.0 AS other_revenues, OLIS.price_reduce AS expense_cost, 0.0 AS downpayment_invoiced FROM project_project P @@ -158,6 +183,7 @@ class ProfitabilityAnalysis(models.Model): SOL.id AS sale_line_id, 0.0 AS timesheet_unit_amount, 0.0 AS timesheet_cost, + 0.0 AS other_revenues, 0.0 AS expense_cost, 0.0 AS downpayment_invoiced FROM sale_order_line SOL @@ -172,6 +198,7 @@ class ProfitabilityAnalysis(models.Model): SOL.id AS sale_line_id, 0.0 AS timesheet_unit_amount, 0.0 AS timesheet_cost, + 0.0 AS other_revenues, 0.0 AS expense_cost, 0.0 AS downpayment_invoiced FROM sale_order_line SOL 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 d0110fa9608..abf410810c5 100644 --- a/addons/sale_timesheet/report/project_profitability_report_analysis_views.xml +++ b/addons/sale_timesheet/report/project_profitability_report_analysis_views.xml @@ -24,6 +24,7 @@ + diff --git a/addons/sale_timesheet/static/src/js/project_overview.js b/addons/sale_timesheet/static/src/js/project_overview.js new file mode 100644 index 00000000000..876a79633ca --- /dev/null +++ b/addons/sale_timesheet/static/src/js/project_overview.js @@ -0,0 +1,56 @@ +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: [], + + config: _.extend({}, qweb.View.prototype.config, { + Controller: Controller, + }), + }); + + viewRegistry.add('project_overview', ProjectOverview); +}); diff --git a/addons/sale_timesheet/tests/test_project_overview.py b/addons/sale_timesheet/tests/test_project_overview.py index 845c928a5f8..df4325d29d7 100644 --- a/addons/sale_timesheet/tests/test_project_overview.py +++ b/addons/sale_timesheet/tests/test_project_overview.py @@ -102,5 +102,5 @@ class TestSaleProject(TestReporting): 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']['total'], project_invoiced + project_timesheet_cost + expense.amount, precision_rounding=rounding), 0, "The total amount should be the one from the SO2 line, as we are in ordered quantity") + self.assertEqual(float_compare(vals['dashboard']['profit']['total'], project_invoiced + project_timesheet_cost + expense.amount, precision_rounding=rounding), 1, "The total amount should be the one from the SO2 line, as we are in ordered quantity") 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/views/hr_timesheet_templates.xml b/addons/sale_timesheet/views/hr_timesheet_templates.xml index 1c65ecfbc07..d37043d670f 100644 --- a/addons/sale_timesheet/views/hr_timesheet_templates.xml +++ b/addons/sale_timesheet/views/hr_timesheet_templates.xml @@ -4,6 +4,7 @@ @@ -12,6 +13,7 @@ qweb project.project +