Merge branch 'pawan_branch' into 'development'
add employee unit hours in project overview screen See merge request prakash.jain/cor-odoo!3
This commit is contained in:
commit
4e4df4ec2b
|
@ -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',
|
||||
],
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
from . import project
|
||||
from . import project
|
||||
from . import project_overview
|
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,564 @@
|
|||
# -*- 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
|
||||
|
||||
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_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)
|
||||
|
||||
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'])
|
||||
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
|
|
@ -0,0 +1,469 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="sale_timesheet.timesheet_plan" model="ir.ui.view">
|
||||
<field name="name">Timesheet Plan</field>
|
||||
<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"
|
||||
t-att-name="action['action_id']"
|
||||
t-att-data-context="action.get('context')"
|
||||
>
|
||||
<t t-esc="action['label']"/>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="o_form_view o_form_readonly o_project_plan">
|
||||
<div class="o_form_sheet_bg">
|
||||
<div class="o_form_sheet o_timesheet_plan_content">
|
||||
<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 o_not_full">
|
||||
<t t-foreach="stat_buttons" t-as="stat_button">
|
||||
<a class="btn oe_stat_button"
|
||||
type="action"
|
||||
t-att="stat_button['action']"
|
||||
>
|
||||
<div t-attf-class="fa fa-fw o_button_icon #{stat_button['icon']}" role="img" aria-label="Statistics" title="Statistics"></div>
|
||||
<div class="o_field_widget o_stat_info o_readonly_modifier" t-att-title="stat_button['name']">
|
||||
<t t-if="not isinstance(stat_button['name'], list)">
|
||||
<span class="o_stat_value" t-if="'count' in stat_button">
|
||||
<t t-esc="stat_button['count']"/>
|
||||
</span>
|
||||
<span class="o_stat_text">
|
||||
<t t-esc="stat_button['name']"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-if="isinstance(stat_button['name'], list)">
|
||||
<div class="oe_inline">
|
||||
<span class="o_stat_value mr-1">
|
||||
<t t-esc="stat_button.get('count')"/>
|
||||
</span>
|
||||
<span class="o_stat_value">
|
||||
<t t-esc="stat_button['name'][0]"/>
|
||||
</span>
|
||||
</div>
|
||||
<span class="o_stat_text">
|
||||
<t t-esc="stat_button['name'][1]"/>
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div class="o_title">
|
||||
<h2 t-if="is_uom_day">Recorded Days and Profitability</h2>
|
||||
<h2 t-else="">Recorded Hours and Profitability</h2>
|
||||
</div>
|
||||
|
||||
<t t-set="display_cost" t-value="dashboard['profit']['expense_cost'] != 0.0"/>
|
||||
<div class="o_profitability_wrapper">
|
||||
<div class="o_profitability_section">
|
||||
<div>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<th>
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain)"
|
||||
data-context='{"pivot_row_groupby": ["date:month"],"pivot_column_groupby": ["timesheet_invoice_type"], "pivot_measures": ["unit_amount"]}'
|
||||
data-views='[[0, "pivot"], [0, "list"]]' tabindex="-1">
|
||||
<span t-if="is_uom_day">Days recorded</span>
|
||||
<span t-else="">Hours recorded</span>
|
||||
</a>
|
||||
</th>
|
||||
<tr>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-if="is_uom_day" t-esc="dashboard['time']['billable_time']" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="dashboard['time']['billable_time']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
(<t t-esc="dashboard['rates']['billable_time']"/> %)
|
||||
</td>
|
||||
<td title="Includes the time logged into tasks for which you invoice based on timesheets on tasks.">
|
||||
Billed on Timesheets
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-if="is_uom_day" t-esc="dashboard['time']['billable_fixed']" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="dashboard['time']['billable_fixed']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
(<t t-esc="dashboard['rates']['billable_fixed']"/> %)
|
||||
</td>
|
||||
<td title="Includes the time logged into tasks for which you invoice based on ordered quantities or on milestones.">
|
||||
Billed at a Fixed price
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="dashboard['time']['non_billable_project'] != 0">
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-if="is_uom_day" t-esc="dashboard['time']['non_billable_project']" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="dashboard['time']['non_billable_project']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
(<t t-esc="dashboard['rates']['non_billable_project']"/> %)
|
||||
</td>
|
||||
<td title="Includes the time logged from the Timesheet module that is linked to a project, but not to a task.">
|
||||
No task found
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-if="is_uom_day" t-esc="dashboard['time']['non_billable']" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="dashboard['time']['non_billable']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
(<t t-esc="dashboard['rates']['non_billable']"/> %)
|
||||
</td>
|
||||
<td>
|
||||
<a type="action"
|
||||
data-model="project.task"
|
||||
data-views='[[false, "list"], [false, "form"]]'
|
||||
t-att-data-domain="json.dumps([['project_id', 'in', projects.ids], ['sale_line_id', '=', False]])"
|
||||
>
|
||||
<span class="btn-link"
|
||||
style="font-weight:normal;"
|
||||
title="Includes the time logged into a task which is not linked to any Sales Order.">
|
||||
Non Billable Tasks
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="dashboard['time']['non_billable_timesheet'] > 0">
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-if="is_uom_day" t-esc="dashboard['time']['non_billable_timesheet']" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="dashboard['time']['non_billable_timesheet']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
(<t t-esc="dashboard['rates']['non_billable_timesheet']"/> %)
|
||||
</td>
|
||||
<td>
|
||||
<a type="action"
|
||||
data-model="account.analytic.line"
|
||||
data-views='[[false, "list"], [false, "form"]]'
|
||||
t-att-data-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','non_billable_timesheet')])"
|
||||
>
|
||||
<span class="btn-link"
|
||||
style="font-weight:normal;">
|
||||
Non Billable Timesheets
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="dashboard['time']['canceled'] > 0">
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-if="is_uom_day" t-esc="dashboard['time']['canceled']" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="dashboard['time']['canceled']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
(<t t-esc="dashboard['rates']['canceled']"/> %)
|
||||
</td>
|
||||
<td title="Includes the time logged into a task which is linked to a cancelled Sales Order.">
|
||||
Cancelled
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="o_timesheet_plan_dashboard_total">
|
||||
<b>
|
||||
<t t-if="is_uom_day" t-esc="dashboard['time']['total']" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="dashboard['time']['total']" t-options="{'widget': 'float_time'}"/>
|
||||
</b>
|
||||
</td>
|
||||
<td><b>Total</b></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_profitability_section">
|
||||
<div>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<th>
|
||||
<a type="action" data-model="project.profitability.report" t-att-data-domain="json.dumps(profitability_domain)" data-context="{'group_by_no_leaf':1, 'group_by':[], 'sale_show_order_product_name': 1}" data-views='[[0, "pivot"], [0, "graph"]]' tabindex="-1">Profitability</a>
|
||||
</th>
|
||||
<tr>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['profit']['invoiced']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td title="Revenues linked to Timesheets already invoiced.">
|
||||
Invoiced
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['profit']['to_invoice']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td title="Revenues linked to Timesheets not yet invoiced.">
|
||||
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}'/>
|
||||
</td>
|
||||
<td title="This cost is based on the "Timesheet cost" set in the HR Settings of your employees.">
|
||||
Timesheet costs
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="display_cost">
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['profit']['expense_cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td title="Any cost linked to the Analytic Account of the Project.">
|
||||
Other costs
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="display_cost & (dashboard['profit']['expense_amount_untaxed_invoiced'] != 0)">
|
||||
<td class="o_timesheet_plan_dashboard_cell">
|
||||
<t t-esc="dashboard['profit']['expense_amount_untaxed_invoiced']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</td>
|
||||
<td title="Costs from expenses that were reinvoiced to your customer (provided that the Analytic Account of the Project was set on the Expense).">
|
||||
Re-invoiced costs
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="o_timesheet_plan_dashboard_total">
|
||||
<b>
|
||||
<t t-esc="dashboard['profit']['total']" t-options='{"widget": "monetary", "display_currency": currency}'/>
|
||||
</b>
|
||||
</td>
|
||||
<td><b>Total</b></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_title">
|
||||
<h2>Time by people</h2>
|
||||
</div>
|
||||
|
||||
<div class="o_timesheet_plan_sale_timesheet_people_time">
|
||||
<t t-if="not repartition_employee">
|
||||
<p>There are no timesheets for now.</p>
|
||||
</t>
|
||||
<t t-if="repartition_employee">
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Employee</th>
|
||||
<th>Unit Price</th>
|
||||
<th t-if="is_uom_day" class="text-nowrap text-right pr-5">Days Spent</th>
|
||||
<th t-else="" class="text-nowrap text-right pr-5">Hours Spent</th>
|
||||
<td>
|
||||
<div class="float-right o_timesheet_plan_badge">
|
||||
<span class="badge badge-pill o_progress_billable_time">
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','billable_time')])" tabindex="-1">Billed on Timesheets</a>
|
||||
</span>
|
||||
<span class="badge badge-pill o_progress_billable_fixed">
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','billable_fixed')])" tabindex="-1">Billed at a Fixed price</a>
|
||||
</span>
|
||||
<span t-if="dashboard['time']['non_billable_project'] != 0" class="badge badge-pill o_progress_non_billable_project">
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','non_billable_project')])" tabindex="-1">No task found</a>
|
||||
</span>
|
||||
<span t-if="dashboard['time']['non_billable_timesheet'] != 0" class="badge badge-pill o_progress_non_billable_timesheet">
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','non_billable_timesheet')])" tabindex="-1">Non billable timesheets</a>
|
||||
</span>
|
||||
<span class="badge badge-pill o_progress_non_billable">
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','non_billable')])" tabindex="-1">Non billable tasks</a>
|
||||
</span>
|
||||
<!-- only show the canceled pill if there were timesheets on canceled so -->
|
||||
<t t-if="sum([employee.get('canceled', 0.0) for employee in repartition_employee.values()]) > 0">
|
||||
<span class="badge badge-pill o_progress_canceled">
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain + [('so_line.state', '=', 'cancel')])" tabindex="-1">Cancelled</a>
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="repartition_employee" t-as="employee_id">
|
||||
<t t-set="employee" t-value="repartition_employee[employee_id]"/>
|
||||
<tr>
|
||||
<td style="width: 3%; vertical-align: middle;">
|
||||
<img class="img rounded-circle mr-2 mb-2" t-attf-src="/web/image?model=hr.employee&field=image_128&id=#{employee['employee_id']}" t-att-title="employee['employee_name']" t-att-alt="employee['employee_name']" width="25" height="25"/>
|
||||
</td>
|
||||
<td style="width: 15%; vertical-align: middle;" >
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain)" t-att-data-context="json.dumps({'search_default_employee_id': employee_id})" data-views="[[0, "list"]]" tabindex="-1">
|
||||
<t t-esc="employee['employee_name']"/>
|
||||
</a>
|
||||
</td>
|
||||
<td style="width: 15%; vertical-align: middle;" >
|
||||
<a type="action" data-model="account.analytic.line" t-att-data-domain="json.dumps(timesheet_domain)" t-att-data-context="json.dumps({'search_default_employee_price': employee_price})" data-views="[[0, "list"]]" tabindex="-1">
|
||||
<t t-esc="employee['employee_price']"/>
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-right pr-5" style="width: 10%; vertical-align: middle;">
|
||||
<t t-if="is_uom_day" t-esc="employee['total']" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="employee['total']" t-options="{'widget': 'float_time'}"/>
|
||||
</td>
|
||||
<td style="vertical-align:middle">
|
||||
<div class="border rounded">
|
||||
<div t-if="repartition_employee_max" class="progress" t-attf-style="width: {{max(0, employee['total'] / repartition_employee_max * 100)}}%; margin-bottom: 0em;">
|
||||
|
||||
<t t-set="total" t-value="employee['total'] or 1.0" />
|
||||
<t t-call="sale_timesheet.progressbar">
|
||||
<t t-set="label">Billed on Timesheets</t>
|
||||
<t t-set="key" t-translation="off">billable_time</t>
|
||||
</t>
|
||||
<t t-call="sale_timesheet.progressbar">
|
||||
<t t-set="label">Billed at a Fixed price</t>
|
||||
<t t-set="key" t-translation="off">billable_fixed</t>
|
||||
</t>
|
||||
<t t-call="sale_timesheet.progressbar">
|
||||
<t t-set="label">No task found</t>
|
||||
<t t-set="key" t-translation="off">non_billable_project</t>
|
||||
</t>
|
||||
<t t-call="sale_timesheet.progressbar">
|
||||
<t t-set="label">Non billable timesheets</t>
|
||||
<t t-set="key" t-translation="off">non_billable_timesheet</t>
|
||||
</t>
|
||||
<t t-call="sale_timesheet.progressbar">
|
||||
<t t-set="label">Non billable tasks</t>
|
||||
<t t-set="key" t-translation="off">non_billable</t>
|
||||
</t>
|
||||
<t t-call="sale_timesheet.progressbar">
|
||||
<t t-set="label">Cancelled</t>
|
||||
<t t-set="key" t-translation="off">canceled</t>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div class="o_title">
|
||||
<h2>Timesheets</h2>
|
||||
</div>
|
||||
|
||||
<!-- NOTE: this template to display a table works whatever the length of the rows, as project_timesheet_forecast_sale extends the table to add forecasts -->
|
||||
<div class="o_project_plan_project_timesheet_forecast">
|
||||
<t t-if="timesheet_forecast_table and timesheet_forecast_table['rows']">
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th colspan="5" id="table_plan_title" class="o_right_bordered"><h3>Timesheets</h3></th>
|
||||
<th colspan="2" id="table_plan_total"></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<t t-foreach="timesheet_forecast_table['header']" t-as="header_val">
|
||||
<th t-att-class="'o_right_bordered' if header_val_index in [5,10] else ''">
|
||||
<span t-att-title="header_val['tooltip']"><t t-esc="header_val['label']"/></span>
|
||||
</th>
|
||||
</t>
|
||||
</tr>
|
||||
</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>
|
||||
<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[0].get('has_children')"/>
|
||||
<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 not in ('sale_order', 'sale_order_line') 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-down' if row_type == 'sale_order' else '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')"/>
|
||||
</a>
|
||||
<span t-if="row_value.get('canceled')" class="badge badge-pill o_canceled_tag">
|
||||
Cancelled
|
||||
</span>
|
||||
</t>
|
||||
<t t-if="row_type != 'sale_order'">
|
||||
<t t-if="not row_is_milestone">
|
||||
<span><t t-esc="row_value.get('label')"/></span>
|
||||
</t>
|
||||
<t t-if="row_is_milestone">
|
||||
<span><i><t t-esc="row_value.get('label')"/></i></span>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
<t t-if="row_value_index != 0">
|
||||
<t t-if="row_value_index < len(row)-2">
|
||||
<t t-if="row_is_milestone">
|
||||
<i t-att-class="'text-muted' if not row_value else ''">
|
||||
<t t-if="is_uom_day" t-esc="row_value" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="row_value" t-options="{'widget': 'float_time'}"/>
|
||||
</i>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-att-class="'text-muted' if not row_value else ''">
|
||||
<t t-if="is_uom_day" t-esc="row_value" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="row_value" t-options="{'widget': 'float_time'}"/>
|
||||
</span>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-if="not row_is_milestone and not row[0].get('type') == 'hr_employee'">
|
||||
<span t-att-class="'text-muted' if not row_value else ''">
|
||||
<t t-if="is_uom_day" t-esc="row_value" t-options="{'widget': 'timesheet_uom'}"/>
|
||||
<t t-else="" t-esc="row_value" t-options="{'widget': 'float_time'}"/>
|
||||
</span>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</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>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</qweb>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
|
@ -29,7 +29,7 @@
|
|||
<field name="sale_line_id" options="{'no_create': True}" attrs="{'column_invisible': [('parent.sale_order_id', '=', False)]}" domain="[('order_id','=',parent.sale_order_id), ('is_service', '=', True)]"/>
|
||||
<field name="price_unit" widget="monetary" options="{'currency_field': 'currency_id'}" attrs="{'readonly': [('parent.sale_order_id', '!=', False)]}"/>
|
||||
<field name="employee_price" widget="monetary" options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<field name="currency_id" invisible="1" readonly="1"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
|
|
Loading…
Reference in New Issue