diff options
| author | Odoo's Mergebot <xmo+roboto@odoo.com> | 2020-01-23 14:35:16 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-01-23 14:35:16 +0100 |
| commit | 28dfe206dfc06cb883207910def53d6205bf21db (patch) | |
| tree | 2657e19a29acec665bb2389ccf3065f040377988 /addons | |
| parent | e73e0ef3c90011c6c3d68554a9becc87cdb7b3ad (diff) | |
| parent | 9fc1dd538b48c5d055799ad6faa24ba5ed9e826d (diff) | |
[MERGE] sale_timesheet: Improve project overview
1) Add the following stat buttons on the right of the Tasks one:
- (fa-tasks) Late Tasks: tasks whose deadline is exceeded (do not include tasks that
don't have a deadline set)
- (fa-tasks) Tasks in Overtime: tasks whose Effective hours are strictly greater than
their Planned hours (if the latter is stricly greater than 0)
2) Remove the sheet view (should be like financial reports)
3) Add an 'Other Revenues' metric under the Profitability section below 'To Invoice'
- It should represent all the revenues other than from timesheets linked to the analytic
account of the project.
- Only visible if greater than 0
4) Allow to fold/unfold the details by SO. It should be folded by default.
Note: Although qweb views have a generic mechanism to manage fold and unfold, this standard
behavior is not used here.
It would imply refactoring the entire code preparing the template data (700+ lines of
code which are hard to read). The quick and dirty way is used for now.
(I wish good luck for the dev if it's one day necessary.)
Task 2119567
--
I confirm I have signed the CLA and read the PR guidelines at www.odoo.com/submit-pr
closes odoo/odoo#40852
Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
Diffstat (limited to 'addons')
10 files changed, 168 insertions, 8 deletions
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 @@ </field> </record> + <record id="project_task_view_search" model="ir.ui.view"> + <field name="name">project.task.view.search.inherit.sale.timesheet.enterprise</field> + <field name="model">project.task</field> + <field name="inherit_id" ref="project.view_task_search_form"/> + <field name="arch" type="xml"> + <xpath expr="//filter[@name='late']" position='after'> + <filter string="Tasks in Overtime" name="overtime" domain="[('overtime', '>', 0)]"/> + </xpath> + </field> + </record> + <!-- Improve the task kanban in Project --> <record id="project_task_view_kanban" model="ir.ui.view"> <field name="name">project.task.kanban.timer</field> 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 @@ <filter string="Unassigned" name="unassigned" domain="[('user_id', '=', False)]"/> <separator/> <filter string="Starred" name="starred" domain="[('priority', 'in', [1, 2])]"/> + <filter string="Late Tasks" name="late" domain="[('date_deadline', '<', context_today().strftime('%Y-%m-%d'))]"/> <separator/> <filter string="Unread Messages" name="message_needaction" domain="[('message_needaction', '=', True)]"/> <separator/> 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 @@ <field name="amount_untaxed_to_invoice" type="measure"/> <field name="amount_untaxed_invoiced" type="measure"/> <field name="timesheet_cost" type="measure"/> + <field name="other_revenues" type="measure"/> </graph> </field> </record> 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 @@ <template id="assets_backend" name="sale timesheet assets" inherit_id="web.assets_backend"> <xpath expr="." position="inside"> <link rel="stylesheet" type="text/scss" href="/sale_timesheet/static/src/scss/sale_timesheet.scss"/> + <script type="text/javascript" src="/sale_timesheet/static/src/js/project_overview.js"></script> </xpath> </template> @@ -12,6 +13,7 @@ <field name="type">qweb</field> <field name="model">project.project</field> <field name="arch" type="xml"> + <qweb js_class="project_overview"> <nav class="o_qweb_cp_buttons" t-if="actions"> <button t-foreach="actions" t-as="action" type="action" class="btn btn-primary" @@ -27,7 +29,7 @@ <div class="o_timesheet_plan_sale_timesheet"> <div class="o_timesheet_plan_sale_timesheet_dashboard"> - <div class="o_timesheet_plan_stat_buttons oe_button_box"> + <div class="o_timesheet_plan_stat_buttons oe_button_box o_not_full"> <t t-foreach="stat_buttons" t-as="stat_button"> <a class="btn oe_stat_button" type="action" @@ -152,6 +154,12 @@ </td> <td>To invoice</td> </tr> + <tr t-if="dashboard['profit']['other_revenues'] > 0"> + <td class="o_timesheet_plan_dashboard_cell"> + <t t-esc="dashboard['profit']['other_revenues']" t-options='{"widget": "monetary", "display_currency": currency}'/> + </td> + <td title="All revenues that are not from timesheets and that are linked to the analytic account of the project.">Other Revenues</td> + </tr> <tr> <td class="o_timesheet_plan_dashboard_cell"> <t t-esc="dashboard['profit']['cost']" t-options='{"widget": "monetary", "display_currency": currency}'/> @@ -289,15 +297,28 @@ </thead> <tbody> <t t-set="row_is_milestone" t-value="False"/> + <t t-set="current_order" t-value="False"/> + <t t-set="current_order_line" t-value="False"/> <t t-foreach="timesheet_forecast_table['rows']" t-as="row"> <t t-set="row_type" t-value="row[0].get('type')"/> <t t-if="row_type == 'sale_order_line'"> <t t-set="row_is_milestone" t-value="row[0].get('is_milestone')"/> </t> - <tr t-att-class="'o_timesheet_forecast_' + row_type"> + <t t-if="row_type == 'sale_order'"> + <t t-set="current_order" t-value="False"/> + <t t-set="current_order_line" t-value="False"/> + </t> + <t t-if="row_type == 'sale_order_line'"> + <t t-set="current_order_line" t-value="False"/> + </t> + <t t-set="foldable" t-value="row_type in ('sale_order', 'sale_order_line')"/> + <tr t-att-class="'o_timesheet_forecast_' + row_type + ' sale_order_' + str(current_order) + ' sale_order_line_' + str(current_order_line)" + t-att-style="'display: none;' if row_type != 'sale_order' else ''"> <t t-foreach="row" t-as="row_value"> <td t-att-class="'o_right_bordered' if row_value_index in [5,10] else '' + ' text-center' if row_value_index != 0 else ''"> <t t-if="row_value_index == 0"> + <span t-if="foldable" t-att-class="'fa fa-caret-right' + (' project_overview_foldable' if foldable else '')" style="cursor: pointer;" + t-att-data-model="row[0].get('res_model')" t-att-data-res-id="row[0].get('res_id')"/> <t t-if="row_type == 'sale_order'"> <a type="action" t-att-data-model="row_value['res_model']" t-att-data-res-id="row_value['res_id']" t-att-class="'o_timesheet_plan_redirect' if row_value['res_id'] else ''"> <t t-esc="row_value.get('label')"/> @@ -333,6 +354,12 @@ </td> </t> </tr> + <t t-if="row_type == 'sale_order_line'"> + <t t-set="current_order_line" t-value="row[0].get('res_id')"/> + </t> + <t t-if="row_type == 'sale_order'"> + <t t-set="current_order" t-value="row[0].get('res_id')"/> + </t> </t> </tbody> </table> @@ -344,6 +371,7 @@ </div> </div> </div> + </qweb> </field> </record> 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'] = '<qweb/>' + root = etree.fromstring(r['arch']) + r['arch'] = etree.tostring(etree.Element('qweb', root.attrib)) return r @api.model |
