summaryrefslogtreecommitdiff
path: root/addons/sale_timesheet/models/project_overview.py
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/sale_timesheet/models/project_overview.py
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/sale_timesheet/models/project_overview.py')
-rw-r--r--addons/sale_timesheet/models/project_overview.py567
1 files changed, 567 insertions, 0 deletions
diff --git a/addons/sale_timesheet/models/project_overview.py b/addons/sale_timesheet/models/project_overview.py
new file mode 100644
index 00000000..9761ac89
--- /dev/null
+++ b/addons/sale_timesheet/models/project_overview.py
@@ -0,0 +1,567 @@
+# -*- 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.bill_type == 'customer_project' 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:
+ stat_buttons.append({
+ 'name': _('Sales Orders'),
+ 'count': len(sale_orders),
+ 'icon': 'fa fa-dollar',
+ 'action': _to_action_data(
+ action=self.env.ref('sale.action_orders').sudo(),
+ domain=[('id', 'in', sale_orders.ids)],
+ context={'create': False, 'edit': False, 'delete': False}
+ )
+ })
+
+ 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')]
+
+ 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')],
+ )
+ })
+
+ 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