From 9c406454d3c84d54771e64c88cddbc0ac912e381 Mon Sep 17 00:00:00 2001 From: Pawan Kumar Date: Fri, 11 Dec 2020 12:47:59 +0530 Subject: [PATCH] add employee unit hours in project overview screen --- cor_custom/__manifest__.py | 1 + cor_custom/models/__init__.py | 3 +- .../__pycache__/__init__.cpython-36.pyc | Bin 213 -> 252 bytes .../project_overview.cpython-36.pyc | Bin 0 -> 19446 bytes cor_custom/models/project_overview.py | 564 ++++++++++++++++++ cor_custom/views/hr_timesheet_templates.xml | 469 +++++++++++++++ cor_custom/views/project_view.xml | 2 +- 7 files changed, 1037 insertions(+), 2 deletions(-) create mode 100644 cor_custom/models/__pycache__/project_overview.cpython-36.pyc create mode 100755 cor_custom/models/project_overview.py create mode 100755 cor_custom/views/hr_timesheet_templates.xml diff --git a/cor_custom/__manifest__.py b/cor_custom/__manifest__.py index 2e73341..dc068eb 100644 --- a/cor_custom/__manifest__.py +++ b/cor_custom/__manifest__.py @@ -26,6 +26,7 @@ 'data': [ # 'security/ir.model.access.csv', 'views/project_view.xml', + 'views/hr_timesheet_templates.xml', 'views/views.xml', 'views/templates.xml', ], diff --git a/cor_custom/models/__init__.py b/cor_custom/models/__init__.py index a0c0dd3..6495c5e 100644 --- a/cor_custom/models/__init__.py +++ b/cor_custom/models/__init__.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- from . import models -from . import project \ No newline at end of file +from . import project +from . import project_overview \ No newline at end of file diff --git a/cor_custom/models/__pycache__/__init__.cpython-36.pyc b/cor_custom/models/__pycache__/__init__.cpython-36.pyc index cdcfb0db09223f6b949dc3701e4d87fe649d7fd9..f0c84adacb3bde1266e44924eebcce52f623c443 100644 GIT binary patch delta 173 zcmcc0_=mCHn3tF9ufXN_BnAeC$3P4ROhA?c5Ep9zi4=wu#vF!R#wbQc5St0eW{P40 zvzdWx<|t+$nJJAd9`AC_gJTxdg}*fHLFr%TkNVGE>X_ kWHecBF%*I9Dq;o^EI?u~b!^p!301(q8L;wH) delta 111 zcmeyvc$Lw`n3tDp>Yj`7&I}9;kAWBtn1Cz?ATE{x5-AKRj5!Rsj8Tk?AT|?_%@oB1 pWHSddXtGQUkdpAzWWL2v1kzN*3?x=E6tRNXtP^KPvVlZ-7y(dS5>NmD diff --git a/cor_custom/models/__pycache__/project_overview.cpython-36.pyc b/cor_custom/models/__pycache__/project_overview.cpython-36.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4a5e6486d3ba3b49b688fa8cf33906ee2a060285 GIT binary patch literal 19446 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', 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']) + for data in profitability_raw_data: + 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['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 + + values['dashboard'] = dashboard_values + + # + # Time Repartition (per employee per billable types) + # + user_ids = self.env['project.task'].sudo().read_group([('project_id', 'in', self.ids), ('user_id', '!=', False)], ['user_id'], ['user_id']) + user_ids = [user_id['user_id'][0] for user_id in user_ids] + employee_ids = self.env['res.users'].sudo().search_read([('id', 'in', user_ids)], ['employee_ids']) + # flatten the list of list + employee_ids = list(itertools.chain.from_iterable([employee_id['employee_ids'] for employee_id in employee_ids])) + + aal_employee_ids = self.env['account.analytic.line'].read_group([('project_id', 'in', self.ids), ('employee_id', '!=', False)], ['employee_id'], ['employee_id']) + 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 + 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, + employee_price=employee.timesheet_cost, + 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], + employee_price=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)] + + # 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']) + 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 diff --git a/cor_custom/views/hr_timesheet_templates.xml b/cor_custom/views/hr_timesheet_templates.xml new file mode 100755 index 0000000..8ecb7e3 --- /dev/null +++ b/cor_custom/views/hr_timesheet_templates.xml @@ -0,0 +1,469 @@ + + + + + Timesheet Plan + qweb + project.project + + + +
+
+
+
+
+ + + +
+

Recorded Days and Profitability

+

Recorded Hours and Profitability

+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Days recorded + Hours recorded + +
+ + + + ( %) + + Billed on Timesheets +
+ + + + ( %) + + Billed at a Fixed price +
+ + + + ( %) + + No task found +
+ + + + ( %) + + + + Non Billable Tasks + + +
+ + + + ( %) + + + + Non Billable Timesheets + + +
+ + + + ( %) + + Cancelled +
+ + + + + Total
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Profitability +
+ + + Invoiced +
+ + + To invoice +
+ + Other Revenues
+ + + Timesheet costs +
+ + + Other costs +
+ + + Re-invoiced costs +
+ + + + Total
+
+
+
+
+ +
+

Time by people

+
+ +
+ +

There are no timesheets for now.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
EmployeeUnit PriceDays SpentHours Spent + +
+ + + + + + + + + + + + + +
+
+ + + + Billed on Timesheets + billable_time + + + Billed at a Fixed price + billable_fixed + + + No task found + non_billable_project + + + Non billable timesheets + non_billable_timesheet + + + Non billable tasks + non_billable + + + Cancelled + canceled + +
+
+
+
+
+
+ +
+

Timesheets

+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Timesheets

+ +
+ + + + + + + + Cancelled + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+
+
+
+
+
+ +
diff --git a/cor_custom/views/project_view.xml b/cor_custom/views/project_view.xml index ef4c630..9133928 100755 --- a/cor_custom/views/project_view.xml +++ b/cor_custom/views/project_view.xml @@ -29,7 +29,7 @@ - +