diff --git a/addons/sale_timesheet/controllers/main.py b/addons/sale_timesheet/controllers/main.py index 08dfb5033da..828ded7a0b8 100644 --- a/addons/sale_timesheet/controllers/main.py +++ b/addons/sale_timesheet/controllers/main.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import babel +from dateutil.relativedelta import relativedelta -from odoo import http, _ +from odoo import http, fields, _ from odoo.http import request -from odoo.osv import expression from odoo.tools import float_round @@ -12,47 +13,49 @@ class SaleTimesheetController(http.Controller): @http.route('/timesheet/plan', type='json', auth="user") def plan(self, domain): - domain = expression.AND([domain, [('project_id', '!=', False)]]) # force timesheet and not AAL - values = self._prepare_plan_values(domain) + """ Get the HTML of the project plan for projects matching the given domain + :param domain: a domain for project.project + """ + projects = request.env['project.project'].search(domain) + values = self._plan_prepare_values(projects) view = request.env.ref('sale_timesheet.timesheet_plan') return { 'html_content': view.render(values) } - def _prepare_plan_values(self, domain): + def _plan_prepare_values(self, projects): - timesheet_lines = request.env['account.analytic.line'].search(domain) currency = request.env.user.company_id.currency_id - - values = { - 'currency': currency, - 'timesheet_lines': timesheet_lines, - 'domain': domain, - } - hour_rounding = request.env.ref('uom.product_uom_hour').rounding + hour_rounding = request.env.ref('product.product_uom_hour').rounding billable_types = ['non_billable', 'non_billable_project', 'billable_time', 'billable_fixed'] - # -- Stat Buttons - values['stat_buttons'] = self._plan_get_stat_button(timesheet_lines) + values = { + 'projects': projects, + 'currency': currency, + 'timesheet_domain': [('project_id', 'in', projects.ids)], + 'stat_buttons': self._plan_get_stat_button(projects), + } - # -- Dashboard (per billable type) + # + # Hours, Rates and Profitability + # dashboard_values = { 'hours': dict.fromkeys(billable_types + ['total'], 0.0), 'rates': dict.fromkeys(billable_types + ['total'], 0.0), - 'money_amount': { + 'profit': { 'invoiced': 0.0, - 'to_invoiced': 0.0, + 'to_invoice': 0.0, 'cost': 0.0, 'total': 0.0, } } - dashboard_domain = domain + [('timesheet_invoice_type', '!=', False)] # force billable type - dashboard_data = request.env['account.analytic.line'].read_group(dashboard_domain, ['unit_amount', 'timesheet_revenue', 'timesheet_invoice_type'], ['timesheet_invoice_type']) + # hours (from timesheet) and rates (by billable type) + dashboard_domain = [('project_id', 'in', projects.ids), ('timesheet_invoice_type', '!=', False)] # force billable type + dashboard_data = request.env['account.analytic.line'].read_group(dashboard_domain, ['unit_amount', 'timesheet_revenue', 'timesheet_invoice_type'], ['timesheet_invoice_type']) dashboard_total_hours = sum([data['unit_amount'] for data in dashboard_data]) for data in dashboard_data: billable_type = data['timesheet_invoice_type'] - # hours dashboard_values['hours'][billable_type] = float_round(data.get('unit_amount'), precision_rounding=hour_rounding) dashboard_values['hours']['total'] += float_round(data.get('unit_amount'), precision_rounding=hour_rounding) # rates @@ -60,26 +63,37 @@ class SaleTimesheetController(http.Controller): dashboard_values['rates'][billable_type] = rate dashboard_values['rates']['total'] += rate - # profitability - project_ids = values['timesheet_lines'].mapped('project_id').ids - money_amount = dict.fromkeys(['invoiced', 'to_invoice', 'cost', 'total'], 0.0) - - profitability_raw_data = request.env['project.profitability.report'].read_group([('project_id', 'in', project_ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_cost'], ['project_id']) + # profitability, using profitability SQL report + profit = dict.fromkeys(['invoiced', 'to_invoice', 'cost', 'total'], 0.0) + profitability_raw_data = request.env['project.profitability.report'].read_group([('project_id', 'in', projects.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_cost'], ['project_id']) for data in profitability_raw_data: - money_amount['invoiced'] += data.get('amount_untaxed_invoiced', 0.0) - money_amount['to_invoice'] += data.get('amount_untaxed_to_invoice', 0.0) - money_amount['cost'] += data.get('timesheet_cost', 0.0) - money_amount['total'] = sum([money_amount[item] for item in money_amount.keys()]) - dashboard_values['money_amount'] = money_amount + profit['invoiced'] += data.get('amount_untaxed_invoiced', 0.0) + profit['to_invoice'] += data.get('amount_untaxed_to_invoice', 0.0) + profit['cost'] += data.get('timesheet_cost', 0.0) + profit['total'] = sum([profit[item] for item in profit.keys()]) + dashboard_values['profit'] = profit values['dashboard'] = dashboard_values - # -- Time Repartition (per employee) - repartition_domain = domain + [('employee_id', '!=', False), ('timesheet_invoice_type', '!=', False)] # force billable type + # + # Time Repartition (per employee per billable types) + # + employees = projects.mapped('tasks.user_id.employee_ids') | request.env['account.analytic.line'].search([('project_id', 'in', projects.ids)]).mapped('employee_id') + repartition_domain = [('project_id', 'in', projects.ids), ('employee_id', '!=', False), ('timesheet_invoice_type', '!=', False)] # force billable type repartition_data = request.env['account.analytic.line'].read_group(repartition_domain, ['employee_id', 'timesheet_invoice_type', 'unit_amount'], ['employee_id', 'timesheet_invoice_type'], lazy=False) # 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, + billable_fixed=0.0, + total=0.0, + ) for data in repartition_data: employee_id = data['employee_id'][0] repartition_employee.setdefault(employee_id, dict( @@ -91,7 +105,7 @@ class SaleTimesheetController(http.Controller): billable_fixed=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'] + repartition_employee[employee_id]['__domain_' + data['timesheet_invoice_type']] = data['__domain'] # compute total for employee_id, vals in repartition_employee.items(): @@ -103,21 +117,102 @@ class SaleTimesheetController(http.Controller): return values - def _plan_get_stat_button(self, timesheet_lines): + def _table_header(self, projects): + 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=request.env.context.get('lang', 'en_US'))[month_index] + + header = [_('Name'), _('Before')] + [_to_short_month_name(date) for date in ts_months] + [_('Done'), _('Sold'), _('Remaining')] + return header + + def _table_row_default(self, projects): + lenght = self._table_header(projects) + return [0.0] * (lenght - 1) # before, M1, M2, M3, Done, Sold, Remaining + + def _table_rows_sql_query(self, projects): + 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(projects.ids), last_ts_month) + return query, query_params + + def _table_rows_get_employee_lines(self, projects, data_from_db): + 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 + default_row_vals = self._table_row_default(projects) + + # 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 request.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 + + # -------------------------------------------------- + # Actions: Stat buttons, ... + # -------------------------------------------------- + + def _plan_get_stat_button(self, projects): stat_buttons = [] stat_buttons.append({ 'name': _('Timesheets'), 'res_model': 'account.analytic.line', - 'domain': [('id', 'in', timesheet_lines.ids)], + 'domain': [('project_id', 'in', projects.ids)], 'icon': 'fa fa-calendar', }) - stat_project_ids = timesheet_lines.mapped('project_id').ids - stat_task_domain = [('project_id', 'in', stat_project_ids), '|', ('stage_id', '=', False), ('stage_id.fold', '=', False)] stat_buttons.append({ 'name': _('Tasks'), - 'count': request.env['project.task'].search_count(stat_task_domain), + 'count': sum(projects.mapped('task_count')), 'res_model': 'project.task', - 'domain': stat_task_domain, + 'domain': [('project_id', 'in', projects.ids)], 'icon': 'fa fa-tasks', }) return stat_buttons diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 53e47d2f867..2a28a279393 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -41,16 +41,16 @@ class Project(models.Model): @api.multi def action_view_timesheet_plan(self): - return { - 'name': _('Overview'), - 'type': 'ir.actions.client', - 'tag': 'timesheet.plan', - 'context': { - 'active_id': self.id, - 'active_ids': self.ids, - 'search_default_project_id': self.id, - } + action = self.env.ref('sale_timesheet.project_timesheet_action_client_timesheet_plan').read()[0] + action['params'] = { + 'project_ids': self.ids, } + action['context'] = { + 'active_id': self.id, + 'active_ids': self.ids, + 'search_default_display_name': self.name, + } + return action class ProjectTask(models.Model): diff --git a/addons/sale_timesheet/static/src/js/timesheet_plan.js b/addons/sale_timesheet/static/src/js/timesheet_plan.js index 0a0048d9720..dba77ff9003 100644 --- a/addons/sale_timesheet/static/src/js/timesheet_plan.js +++ b/addons/sale_timesheet/static/src/js/timesheet_plan.js @@ -33,7 +33,7 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, { var self = this; var view_id = this.action && this.action.search_view_id && this.action.search_view_id[0]; var def = this - .loadViews('account.analytic.line', this.action.context || {}, [[view_id, 'search']]) + .loadViews('project.project', this.action.context || {}, [[view_id, 'search']]) .then(function (result) { self.fields_view = result.search; }); @@ -63,7 +63,7 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, { search_defaults: search_defaults, }; - var dataset = new data.DataSetSearch(this, 'account.analytic.line'); + var dataset = new data.DataSetSearch(this, 'project.project'); this.searchview = new SearchView(this, dataset, this.fields_view, options); this.searchview.on('search', this, this._onSearch); diff --git a/addons/sale_timesheet/static/tests/timesheet_plan_tests.js b/addons/sale_timesheet/static/tests/timesheet_plan_tests.js index d352da61dc9..d1d71c4face 100644 --- a/addons/sale_timesheet/static/tests/timesheet_plan_tests.js +++ b/addons/sale_timesheet/static/tests/timesheet_plan_tests.js @@ -26,7 +26,7 @@ function createPlan(params) { QUnit.module('Timesheet Plan', { beforeEach: function () { this.data = { - 'account.analytic.line': { + 'project.project': { fields: { account_id: {string: "Analytic account", type: "many2one", relation: "account.analytic.account"}, }, @@ -41,7 +41,7 @@ QUnit.module('Timesheet Plan', { var plan = createPlan({ archs: { - 'account.analytic.line,false,search': '', + 'project.project,false,search': '', }, data: this.data, action: {}, @@ -61,7 +61,7 @@ QUnit.module('Timesheet Plan', { }, }); assert.verifySteps([ - "/web/dataset/call_kw/account.analytic.line", + "/web/dataset/call_kw/project.project", "/timesheet/plan" ]); assert.strictEqual(plan.$el.text(), 'Banach-Tarski', 'should have rendered html content'); @@ -75,7 +75,7 @@ QUnit.module('Timesheet Plan', { var plan = createPlan({ archs: { - 'account.analytic.line,false,search': '', + 'project.project,false,search': '', }, data: this.data, action: {name: 'Fibonacci'}, diff --git a/addons/sale_timesheet/views/hr_timesheet_templates.xml b/addons/sale_timesheet/views/hr_timesheet_templates.xml index 59327067366..8aa9410dcab 100644 --- a/addons/sale_timesheet/views/hr_timesheet_templates.xml +++ b/addons/sale_timesheet/views/hr_timesheet_templates.xml @@ -43,24 +43,24 @@ - Hours + Hours Timesheets - Rates + Rates % Timesheets - Profitability + Profitability - + Invoiced @@ -74,7 +74,7 @@ Fixed - + To invoice @@ -88,19 +88,19 @@ No task found - + - Cost + Timesheet costs - Non Billable + Non billable % - Non Billable + Non billable @@ -112,7 +112,7 @@ - + Total @@ -128,16 +128,16 @@ @@ -145,7 +145,7 @@ @@ -153,7 +153,7 @@
- + -
+
diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index 230999e2974..b659aaa9ba4 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -16,6 +16,17 @@ + + project.project.search.inherit + project.project + + + + + + + + sale.service.form.view.inherit project.task @@ -47,4 +58,12 @@ + + + Overview + timesheet.plan + project.project + + +