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 @@
qwebproject.project
+
diff --git a/addons/web/models/models.py b/addons/web/models/models.py
index 0d03d950b9f..3a70ab4f0c8 100644
--- a/addons/web/models/models.py
+++ b/addons/web/models/models.py
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
import babel.dates
import pytz
+from lxml import etree
from odoo import _, api, fields, models
from odoo.osv.expression import AND
@@ -202,9 +203,11 @@ class Base(models.AbstractModel):
def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False):
r = super().fields_view_get(view_id, view_type, toolbar, submenu)
# avoid leaking the raw (un-rendered) template, also avoids bloating
- # the response payload for no reason
+ # the response payload for no reason. Only send the root node,
+ # to send attributes such as `js_class`.
if r['type'] == 'qweb':
- r['arch'] = ''
+ root = etree.fromstring(r['arch'])
+ r['arch'] = etree.tostring(etree.Element('qweb', root.attrib))
return r
@api.model