[IMP] sale_timesheet: remove overview and improve updates
This commit removes the project overview feature and views. This commit improves the project updates right panel informations and adds the buttons added in the project form view of this module. /!\ This commit also improves the timesheet_invoice_type granularity and precision. The project overview view lacks of genericity and lacks of clarity regarding the information to report. PR : #72736 See odoo/upgrade#2706 task-2545084
This commit is contained in:
committed by
Laurent Stukkens (LTU)
parent
66bf742c15
commit
2f77c86275
@@ -25,7 +25,6 @@ have real delivered quantities in sales orders.
|
||||
'views/project_update_templates.xml',
|
||||
'views/hr_timesheet_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/hr_timesheet_templates.xml',
|
||||
'views/sale_timesheet_portal_templates.xml',
|
||||
'views/project_sharing_views.xml',
|
||||
'report/project_profitability_report_analysis_views.xml',
|
||||
@@ -44,8 +43,6 @@ have real delivered quantities in sales orders.
|
||||
'sale_timesheet/static/src/scss/sale_timesheet_portal.scss',
|
||||
],
|
||||
'web.assets_backend': [
|
||||
'sale_timesheet/static/src/scss/sale_timesheet.scss',
|
||||
'sale_timesheet/static/src/js/project_overview.js',
|
||||
'sale_timesheet/static/src/js/so_line_one2many.js',
|
||||
],
|
||||
'web.assets_tests': [
|
||||
|
||||
@@ -5,7 +5,6 @@ from . import account
|
||||
from . import account_move
|
||||
from . import product
|
||||
from . import project
|
||||
from . import project_overview
|
||||
from . import project_update
|
||||
from . import sale_order
|
||||
from . import res_config_settings
|
||||
|
||||
@@ -18,7 +18,11 @@ class AccountAnalyticLine(models.Model):
|
||||
('billable_time', 'Billed on Timesheets'),
|
||||
('billable_fixed', 'Billed at a Fixed price'),
|
||||
('non_billable', 'Non Billable Tasks'),
|
||||
('non_billable_project', 'No task found')], string="Billable Type", compute='_compute_timesheet_invoice_type', compute_sudo=True, store=True, readonly=True)
|
||||
('timesheet_revenues', 'Timesheet Revenues'),
|
||||
('service_revenues', 'Service Revenues'),
|
||||
('other_revenues', 'Other Revenues'),
|
||||
('other_costs', 'Other Costs')], string="Billable Type",
|
||||
compute='_compute_timesheet_invoice_type', compute_sudo=True, store=True, readonly=True)
|
||||
commercial_partner_id = fields.Many2one('res.partner', compute="_compute_commercial_partner")
|
||||
timesheet_invoice_id = fields.Many2one('account.move', string="Invoice", readonly=True, copy=False, help="Invoice created from the timesheet")
|
||||
so_line = fields.Many2one(compute="_compute_so_line", store=True, readonly=False, domain="[('is_service', '=', True), ('is_expense', '=', False), ('state', 'in', ['sale', 'done']), ('order_partner_id', 'child_of', commercial_partner_id)]")
|
||||
@@ -37,23 +41,25 @@ class AccountAnalyticLine(models.Model):
|
||||
if not self.is_so_line_edited:
|
||||
self.is_so_line_edited = True
|
||||
|
||||
# TODO: [XBO] Since the task_id is not required in this model, then it should more efficient to depends to pricing_type of project (See in master)
|
||||
@api.depends('so_line.product_id', 'project_id', 'task_id', 'task_id.pricing_type')
|
||||
@api.depends('so_line.product_id', 'project_id', 'amount')
|
||||
def _compute_timesheet_invoice_type(self):
|
||||
for timesheet in self:
|
||||
if timesheet.project_id: # AAL will be set to False
|
||||
invoice_type = 'non_billable_project' if not timesheet.task_id else 'non_billable'
|
||||
if timesheet.task_id and timesheet.so_line.product_id.type == 'service':
|
||||
invoice_type = 'non_billable' if not timesheet.so_line else False
|
||||
if timesheet.so_line and timesheet.so_line.product_id.type == 'service':
|
||||
if timesheet.so_line.product_id.invoice_policy == 'delivery':
|
||||
if timesheet.so_line.product_id.service_type == 'timesheet':
|
||||
invoice_type = 'billable_time'
|
||||
invoice_type = 'timesheet_revenues' if timesheet.amount > 0 else 'billable_time'
|
||||
else:
|
||||
invoice_type = 'billable_fixed'
|
||||
elif timesheet.so_line.product_id.invoice_policy == 'order':
|
||||
invoice_type = 'billable_fixed'
|
||||
timesheet.timesheet_invoice_type = invoice_type
|
||||
else:
|
||||
timesheet.timesheet_invoice_type = False
|
||||
if timesheet.so_line and timesheet.so_line.product_id.type == 'service':
|
||||
timesheet.timesheet_invoice_type = 'service_revenues'
|
||||
else:
|
||||
timesheet.timesheet_invoice_type = 'other_revenues' if timesheet.amount >= 0 else 'other_costs'
|
||||
|
||||
@api.depends('task_id.sale_line_id', 'project_id.sale_line_id', 'employee_id', 'project_id.allow_billable')
|
||||
def _compute_so_line(self):
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.osv import expression
|
||||
from odoo.exceptions import ValidationError, UserError
|
||||
from odoo.tools import format_amount, float_round, float_is_zero
|
||||
from odoo.tools.misc import formatLang
|
||||
|
||||
from odoo.tools import format_amount, float_is_zero
|
||||
|
||||
# YTI PLEASE SPLIT ME
|
||||
class Project(models.Model):
|
||||
@@ -39,6 +38,9 @@ class Project(models.Model):
|
||||
help="Employee/Sale Order Item Mapping:\n Defines to which sales order item an employee's timesheet entry will be linked."
|
||||
"By extension, it defines the rate at which an employee's time on the project is billed.")
|
||||
allow_billable = fields.Boolean("Billable", help="Invoice your time and material from tasks.")
|
||||
billable_percentage = fields.Integer(
|
||||
compute='_compute_billable_percentage',
|
||||
help="% of timesheets that are billable compared to the total number of timesheets linked to the AA of the project, rounded to the unit.")
|
||||
display_create_order = fields.Boolean(compute='_compute_display_create_order')
|
||||
timesheet_product_id = fields.Many2one(
|
||||
'product.product', string='Timesheet Product',
|
||||
@@ -108,6 +110,21 @@ class Project(models.Model):
|
||||
domain = expression.AND([domain, [('allow_billable', '=', True)]])
|
||||
return domain
|
||||
|
||||
@api.depends('analytic_account_id', 'timesheet_ids')
|
||||
def _compute_billable_percentage(self):
|
||||
timesheets_read_group = self.env['account.analytic.line'].read_group([('project_id', 'in', self.ids)], ['project_id', 'so_line', 'unit_amount'], ['project_id', 'so_line'], lazy=False)
|
||||
timesheets_by_project = defaultdict(list)
|
||||
for res in timesheets_read_group:
|
||||
timesheets_by_project[res['project_id'][0]].append((res['unit_amount'], bool(res['so_line'])))
|
||||
for project in self:
|
||||
timesheet_total = timesheet_billable = 0.0
|
||||
for unit_amount, is_billable_timesheet in timesheets_by_project[project.id]:
|
||||
timesheet_total += unit_amount
|
||||
if is_billable_timesheet:
|
||||
timesheet_billable += unit_amount
|
||||
billable_percentage = timesheet_billable / timesheet_total * 100 if timesheet_total > 0 else 0
|
||||
project.billable_percentage = round(billable_percentage)
|
||||
|
||||
@api.depends('partner_id', 'pricing_type')
|
||||
def _compute_display_create_order(self):
|
||||
for project in self:
|
||||
@@ -134,12 +151,6 @@ class Project(models.Model):
|
||||
|
||||
(self - projects).warning_employee_rate = False
|
||||
|
||||
@api.depends('analytic_account_id', 'allow_billable', 'allow_timesheets')
|
||||
def _compute_project_overview(self):
|
||||
super()._compute_project_overview()
|
||||
for project in self.filtered(lambda p: not p.project_overview):
|
||||
project.project_overview = project.allow_billable or project.allow_timesheets
|
||||
|
||||
@api.depends('sale_line_employee_ids.sale_line_id', 'sale_line_id')
|
||||
def _compute_partner_id(self):
|
||||
for project in self:
|
||||
@@ -188,10 +199,29 @@ class Project(models.Model):
|
||||
sale_line_id = project.sale_line_employee_ids.filtered(lambda l: l.project_id == project and l.employee_id == employee_id).sale_line_id
|
||||
timesheet_ids.filtered(lambda t: t.employee_id == employee_id).sudo().so_line = sale_line_id
|
||||
|
||||
def action_open_project_invoices(self):
|
||||
invoices = self.env['account.move'].search([
|
||||
('line_ids.analytic_account_id', '!=', False),
|
||||
('line_ids.analytic_account_id', 'in', self.analytic_account_id.ids),
|
||||
('move_type', '=', 'out_invoice')
|
||||
])
|
||||
action = {
|
||||
'name': _('Invoices'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'account.move',
|
||||
'views': [[False, 'tree'], [False, 'form'], [False, 'kanban']],
|
||||
'domain': [('id', 'in', invoices.ids)],
|
||||
'context': {
|
||||
'create': False,
|
||||
}
|
||||
}
|
||||
if len(invoices) == 1:
|
||||
action['views'] = [[False, 'form']]
|
||||
action['res_id'] = invoices.id
|
||||
return action
|
||||
|
||||
def action_view_timesheet(self):
|
||||
self.ensure_one()
|
||||
if self.allow_timesheets:
|
||||
return self.action_view_timesheet_plan()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Timesheets of %s', self.name),
|
||||
@@ -215,18 +245,6 @@ class Project(models.Model):
|
||||
}
|
||||
}
|
||||
|
||||
def action_view_timesheet_plan(self):
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("sale_timesheet.project_timesheet_action_client_timesheet_plan")
|
||||
action['params'] = {
|
||||
'project_ids': self.ids,
|
||||
}
|
||||
action['context'] = {
|
||||
'active_id': self.id,
|
||||
'active_ids': self.ids,
|
||||
'search_default_name': self.name,
|
||||
}
|
||||
return action
|
||||
|
||||
def action_make_billable(self):
|
||||
return {
|
||||
"name": _("Create Sales Order"),
|
||||
@@ -241,6 +259,27 @@ class Project(models.Model):
|
||||
},
|
||||
}
|
||||
|
||||
def action_billable_time_button(self):
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("hr_timesheet.timesheet_action_all")
|
||||
action.update({
|
||||
'context': {
|
||||
'search_default_groupby_timesheet_invoice_type': True,
|
||||
'default_project_id': self.id,
|
||||
},
|
||||
'domain': [('project_id', '=', self.id)],
|
||||
'view_mode': 'tree,grid,kanban,pivot,graph,form',
|
||||
'views': [
|
||||
[self.env.ref('hr_timesheet.timesheet_view_tree_user').id, 'tree'],
|
||||
[self.env.ref('timesheet_grid.timesheet_view_grid_by_employee').id, 'grid'],
|
||||
[self.env.ref('hr_timesheet.view_kanban_account_analytic_line').id, 'kanban'],
|
||||
[self.env.ref('hr_timesheet.view_hr_timesheet_line_pivot').id, 'pivot'],
|
||||
[self.env.ref('hr_timesheet.view_hr_timesheet_line_graph_all').id, 'graph'],
|
||||
[self.env.ref('timesheet_grid.timesheet_view_form').id, 'form'],
|
||||
],
|
||||
})
|
||||
return action
|
||||
|
||||
# ----------------------------
|
||||
# Project Updates
|
||||
# ----------------------------
|
||||
@@ -249,26 +288,52 @@ class Project(models.Model):
|
||||
panel_data = super(Project, self).get_panel_data()
|
||||
return {
|
||||
**panel_data,
|
||||
'analytic_account_id': self.analytic_account_id.id,
|
||||
'sold_items': self._get_sold_items(),
|
||||
'profitability_items': self._get_profitability_items()
|
||||
}
|
||||
|
||||
def _get_sale_order_lines(self):
|
||||
sale_orders = self.sale_order_id | self.tasks.sale_order_id
|
||||
return self.env['sale.order.line'].search([('order_id', 'in', sale_orders.ids), ('is_service', '=', True)], order='id asc')
|
||||
|
||||
def _get_sold_items(self):
|
||||
sols = self._get_sale_order_lines()
|
||||
number_sale_orders = len(sols.order_id)
|
||||
sold_items = {
|
||||
'allow_billable': self.allow_billable,
|
||||
'data': [],
|
||||
'number_sols': len(sols),
|
||||
'total_sold': 0,
|
||||
'effective_sold': 0,
|
||||
'company_unit_name': self.env.company.timesheet_encode_uom_id.name
|
||||
}
|
||||
for sol in sols:
|
||||
name = [x[1] for x in sol.name_get()] if number_sale_orders > 1 else sol.name
|
||||
qty_delivered = int(sol.qty_delivered) if sol.qty_delivered.is_integer() else sol.qty_delivered
|
||||
product_uom_qty = int(sol.product_uom_qty) if sol.product_uom_qty.is_integer() else sol.product_uom_qty
|
||||
sold_items['data'].append({
|
||||
'name': name,
|
||||
'value': '%s / %s %s' % (qty_delivered, product_uom_qty, sol.product_uom.name),
|
||||
'color': 'red' if qty_delivered > product_uom_qty else 'black'
|
||||
})
|
||||
#We only want to consider hours and days for this calculation
|
||||
if sol.product_uom.category_id == self.env.company.timesheet_encode_uom_id.category_id:
|
||||
sold_items['total_sold'] += sol.product_uom._compute_quantity(sol.product_uom_qty, self.env.company.timesheet_encode_uom_id, raise_if_failure=False)
|
||||
sold_items['effective_sold'] = round(sol.product_uom._compute_quantity(self.total_timesheet_time, self.env.company.timesheet_encode_uom_id, raise_if_failure=False), 2)
|
||||
remaining = sold_items['total_sold'] - sold_items['effective_sold']
|
||||
sold_items['remaining'] = {
|
||||
'value': remaining,
|
||||
'color': 'red' if remaining < 0 else 'black',
|
||||
}
|
||||
return sold_items
|
||||
|
||||
def _get_profitability_items(self):
|
||||
if not self.user_has_groups('project.group_project_manager'):
|
||||
return {'data': []}
|
||||
timesheets = self.allow_timesheets and self.user_has_groups('hr_timesheet.group_hr_timesheet_user')
|
||||
profitability = self._get_profitability_common(costs_revenues=self.allow_billable, timesheets=timesheets)
|
||||
data = []
|
||||
if timesheets:
|
||||
data += [{
|
||||
'name': _("Timesheets"),
|
||||
'value': self.env.ref('sale_timesheet.project_profitability_timesheet_panel')._render({
|
||||
'timesheet_unit_amount': float_round(profitability['timesheet_unit_amount'], precision_digits=2),
|
||||
'timesheet_uom': self.env.company._timesheet_uom_text(),
|
||||
'is_timesheet_uom_hour': self.env.company._is_timesheet_hour_uom(),
|
||||
'percentage_billable': formatLang(self.env, profitability['timesheet_percentage_billable'], digits=0),
|
||||
}, engine='ir.qweb'),
|
||||
}]
|
||||
if self.allow_billable:
|
||||
profitability = self._get_profitability_common()
|
||||
margin_color = False
|
||||
if not float_is_zero(profitability['margin'], precision_digits=0):
|
||||
margin_color = profitability['margin'] > 0 and 'green' or 'red'
|
||||
@@ -289,51 +354,54 @@ class Project(models.Model):
|
||||
'data': data,
|
||||
}
|
||||
|
||||
def _get_profitability_common(self, costs_revenues=True, timesheets=True):
|
||||
def _get_profitability_common(self):
|
||||
self.ensure_one()
|
||||
result = {
|
||||
'costs': 0.0,
|
||||
'margin': 0.0,
|
||||
'revenues': 0.0,
|
||||
'timesheet_unit_amount': 0.0,
|
||||
'timesheet_percentage_billable': 0.0,
|
||||
'revenues': 0.0
|
||||
}
|
||||
if costs_revenues:
|
||||
profitability = self.env['project.profitability.report'].read_group(
|
||||
[('project_id', '=', self.id)],
|
||||
['project_id',
|
||||
'amount_untaxed_to_invoice',
|
||||
'amount_untaxed_invoiced',
|
||||
'expense_amount_untaxed_to_invoice',
|
||||
'expense_amount_untaxed_invoiced',
|
||||
'other_revenues',
|
||||
'expense_cost',
|
||||
'timesheet_cost',
|
||||
'margin'],
|
||||
['project_id'], limit=1)
|
||||
if profitability:
|
||||
profitability = profitability[0]
|
||||
result.update({
|
||||
'costs': profitability['timesheet_cost'] + profitability['expense_cost'],
|
||||
'margin': profitability['margin'],
|
||||
'revenues': (profitability['amount_untaxed_invoiced'] + profitability['amount_untaxed_to_invoice'] +
|
||||
profitability['expense_amount_untaxed_invoiced'] + profitability['expense_amount_untaxed_to_invoice'] +
|
||||
profitability['other_revenues']),
|
||||
})
|
||||
if timesheets:
|
||||
timesheets = self.env['account.analytic.line'].read_group([('project_id', '=', self.id)], ['so_line', 'unit_amount'], ['so_line'])
|
||||
timesheet_billable = timesheet_non_billable = 0.0
|
||||
for timesheet in timesheets:
|
||||
if timesheet['so_line']:
|
||||
timesheet_billable += timesheet['unit_amount']
|
||||
else:
|
||||
timesheet_non_billable += timesheet['unit_amount']
|
||||
|
||||
profitability = self.env['project.profitability.report'].read_group(
|
||||
[('project_id', '=', self.id)],
|
||||
['project_id',
|
||||
'amount_untaxed_to_invoice',
|
||||
'amount_untaxed_invoiced',
|
||||
'expense_amount_untaxed_to_invoice',
|
||||
'expense_amount_untaxed_invoiced',
|
||||
'other_revenues',
|
||||
'expense_cost',
|
||||
'timesheet_cost',
|
||||
'margin'],
|
||||
['project_id'], limit=1)
|
||||
if profitability:
|
||||
profitability = profitability[0]
|
||||
result.update({
|
||||
'timesheet_unit_amount': self._convert_project_uom_to_timesheet_encode_uom(timesheet_billable + timesheet_non_billable),
|
||||
'timesheet_percentage_billable': timesheet_billable and timesheet_billable / (timesheet_billable + timesheet_non_billable) * 100 or 0.0,
|
||||
'costs': profitability['timesheet_cost'] + profitability['expense_cost'],
|
||||
'margin': profitability['margin'],
|
||||
'revenues': (profitability['amount_untaxed_invoiced'] + profitability['amount_untaxed_to_invoice'] +
|
||||
profitability['expense_amount_untaxed_invoiced'] + profitability['expense_amount_untaxed_to_invoice'] +
|
||||
profitability['other_revenues']),
|
||||
})
|
||||
return result
|
||||
|
||||
def _get_stat_buttons(self):
|
||||
buttons = super(Project, self)._get_stat_buttons()
|
||||
buttons.append({
|
||||
'icon': 'clock-o',
|
||||
'text': _('Billable Time'),
|
||||
'number': '%s %%' % (self.billable_percentage),
|
||||
'action_type': 'object',
|
||||
'action': 'action_billable_time_button',
|
||||
'additional_context': json.dumps({
|
||||
'active_id': self.id,
|
||||
'default_project_id': self.id
|
||||
}),
|
||||
'show': self.user_has_groups('hr_timesheet.group_hr_timesheet_approver') and self.allow_timesheets and bool(self.analytic_account_id),
|
||||
'sequence': 9,
|
||||
})
|
||||
return buttons
|
||||
|
||||
class ProjectTask(models.Model):
|
||||
_inherit = "project.task"
|
||||
|
||||
|
||||
@@ -1,580 +0,0 @@
|
||||
# -*- 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.pricing_type != 'task_rate' 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:
|
||||
so_action = dict(
|
||||
context={'create': False, 'edit': False, 'delete': False},
|
||||
domain=[('id', 'in', sale_orders.ids)],
|
||||
)
|
||||
|
||||
if len(sale_orders) == 1:
|
||||
so_action.update({
|
||||
'action': self.env.ref('sale.action_sale_order_form_view').sudo(),
|
||||
'res_id': sale_orders.id,
|
||||
})
|
||||
else:
|
||||
so_action['action'] = self.env.ref('sale.action_orders').sudo()
|
||||
|
||||
stat_buttons.append({
|
||||
'name': _('Sales Orders'),
|
||||
'count': len(sale_orders),
|
||||
'icon': 'fa fa-dollar',
|
||||
'action': _to_action_data(**so_action),
|
||||
})
|
||||
|
||||
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')]
|
||||
|
||||
default_project_ctx = {}
|
||||
if len(self) == 1:
|
||||
default_project_ctx = {'default_project_id': self.id}
|
||||
|
||||
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')],
|
||||
context=default_project_ctx,
|
||||
)
|
||||
})
|
||||
|
||||
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
|
||||
@@ -1,10 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from odoo import api, models
|
||||
from odoo.tools import float_utils, format_amount, formatLang
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.tools import float_utils, format_amount, formatLang, format_date, DEFAULT_SERVER_DATE_FORMAT
|
||||
from collections import defaultdict
|
||||
|
||||
class ProjectUpdate(models.Model):
|
||||
_inherit = 'project.update'
|
||||
@@ -13,55 +13,66 @@ class ProjectUpdate(models.Model):
|
||||
def _get_template_values(self, project):
|
||||
return {
|
||||
**super(ProjectUpdate, self)._get_template_values(project),
|
||||
'services': self._get_services_values(project),
|
||||
'profitability': self._get_profitability_values(project),
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_project_sols(self, project):
|
||||
return self.env['sale.order.line'].search([('order_id', '=', project.sale_order_id.id), ('is_service', '=', True)])
|
||||
|
||||
@api.model
|
||||
def _get_services_values(self, project):
|
||||
if not project.allow_billable:
|
||||
return {}
|
||||
services = []
|
||||
total_sold_per_uom = defaultdict(float)
|
||||
total_effective_per_uom = defaultdict(float)
|
||||
total_sold, total_effective, total_remaining = 0, 0, 0
|
||||
sols = self._get_project_sols(project)
|
||||
for sol in sols:
|
||||
#We only want to consider hours and days for this calculation
|
||||
if sol.product_uom.category_id == self.env.company.timesheet_encode_uom_id.category_id:
|
||||
services.append({
|
||||
'name': sol.name,
|
||||
'uom': sol.product_uom.name,
|
||||
'sold_value': sol.product_uom_qty,
|
||||
'effective_value': sol.qty_delivered,
|
||||
'remaining_value': sol.product_uom_qty - sol.qty_delivered,
|
||||
'sol': sol,
|
||||
})
|
||||
total_sold_per_uom[sol.product_uom] += sol.product_uom_qty
|
||||
total_effective_per_uom[sol.product_uom] += sol.qty_delivered
|
||||
total_sold = sum(
|
||||
uom._compute_quantity(sol.product_uom_qty, self.env.company.timesheet_encode_uom_id, raise_if_failure=False)
|
||||
for uom, quantity in total_sold_per_uom.items()
|
||||
)
|
||||
total_effective = sum(
|
||||
uom._compute_quantity(sol.product_uom_qty, self.env.company.timesheet_encode_uom_id, raise_if_failure=False)
|
||||
for uom, quantity in total_effective_per_uom.items()
|
||||
)
|
||||
total_remaining = total_sold - total_effective
|
||||
return {
|
||||
'data': services,
|
||||
'total_sold': total_sold,
|
||||
'total_effective': total_effective,
|
||||
'total_remaining': total_remaining,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_profitability_values(self, project):
|
||||
costs_revenues = project.analytic_account_id and project.allow_billable
|
||||
timesheets = project.allow_timesheets and self.user_has_groups('hr_timesheet.group_hr_timesheet_user')
|
||||
if not (self.user_has_groups('project.group_project_manager') and (costs_revenues or timesheets)):
|
||||
return {}
|
||||
this_month = fields.Date.context_today(self) + relativedelta(day=1)
|
||||
previous_month = this_month + relativedelta(months=-1, day=1)
|
||||
result = {
|
||||
'allow_timesheets': timesheets,
|
||||
'allow_costs_and_revenues': costs_revenues,
|
||||
profitability = project._get_profitability_common()
|
||||
return {
|
||||
'analytic_account_id': project.analytic_account_id,
|
||||
'month': format_date(self.env, this_month, date_format='LLLL y'),
|
||||
'previous_month': format_date(self.env, previous_month, date_format='LLLL'),
|
||||
'is_timesheet_uom_hour': self.env.company._is_timesheet_hour_uom(),
|
||||
'timesheet_uom': self.env.company._timesheet_uom_text(),
|
||||
'timesheet_unit_amount': '', 'previous_timesheet_unit_amount': '',
|
||||
'timesheet_trend': '', 'costs': '', 'revenues': '', 'margin': '',
|
||||
'margin_formatted': '', 'margin_percentage': '', 'billing_rate': '',
|
||||
'costs': format_amount(self.env, -profitability['costs'], self.env.company.currency_id),
|
||||
'revenues': format_amount(self.env, profitability['revenues'], self.env.company.currency_id),
|
||||
'margin': profitability['margin'],
|
||||
'margin_formatted': format_amount(self.env, profitability['margin'], self.env.company.currency_id),
|
||||
'margin_percentage': formatLang(self.env,
|
||||
not float_utils.float_is_zero(profitability['costs'], precision_digits=2) and -(profitability['margin'] / profitability['costs']) * 100 or 0.0,
|
||||
digits=0),
|
||||
}
|
||||
if timesheets:
|
||||
timesheets_per_month = self.env['account.analytic.line'].read_group(
|
||||
[('project_id', '=', project.id),
|
||||
('date', '>=', previous_month)],
|
||||
['date', 'unit_amount'],
|
||||
['date:month'])
|
||||
timesheet_unit_amount = {ts['__range']['date']['from']: ts['unit_amount'] for ts in timesheets_per_month}
|
||||
this_amount = timesheet_unit_amount.get(this_month.replace(day=1).strftime(DEFAULT_SERVER_DATE_FORMAT), 0.0)
|
||||
previous_amount = timesheet_unit_amount.get(previous_month.replace(day=1).strftime(DEFAULT_SERVER_DATE_FORMAT), 0.0)
|
||||
result.update({
|
||||
'timesheet_unit_amount': formatLang(self.env, project._convert_project_uom_to_timesheet_encode_uom(this_amount), digits=0),
|
||||
'previous_timesheet_unit_amount': formatLang(self.env, project._convert_project_uom_to_timesheet_encode_uom(previous_amount), digits=0),
|
||||
'timesheet_trend': formatLang(self.env, previous_amount > 0 and ((this_amount / previous_amount) - 1) * 100 or 0.0, digits=0),
|
||||
})
|
||||
if costs_revenues:
|
||||
profitability = project._get_profitability_common(costs_revenues=True, timesheets=False)
|
||||
result.update({
|
||||
'costs': format_amount(self.env, -profitability['costs'], self.env.company.currency_id),
|
||||
'revenues': format_amount(self.env, profitability['revenues'], self.env.company.currency_id),
|
||||
'margin': profitability['margin'],
|
||||
'margin_formatted': format_amount(self.env, profitability['margin'], self.env.company.currency_id),
|
||||
'margin_percentage': formatLang(self.env,
|
||||
not float_utils.float_is_zero(profitability['costs'], precision_digits=2) and -(profitability['margin'] / profitability['costs']) * 100 or 0.0,
|
||||
digits=0),
|
||||
'billing_rate': formatLang(self.env,
|
||||
not float_utils.float_is_zero(profitability['costs'], precision_digits=2) and -(profitability['revenues'] / profitability['costs']) * 100 or 0.0,
|
||||
digits=0),
|
||||
})
|
||||
return result
|
||||
|
||||
@@ -94,15 +94,6 @@ class SaleOrder(models.Model):
|
||||
) >= 0
|
||||
)
|
||||
|
||||
def action_view_project_ids(self):
|
||||
self.ensure_one()
|
||||
# redirect to form or kanban view
|
||||
if len(self.project_ids) == 1 and self.project_ids.project_overview and self.env.user.has_group('project.group_project_manager'):
|
||||
action = self.project_ids.action_view_timesheet_plan()
|
||||
else:
|
||||
action = super().action_view_project_ids()
|
||||
return action
|
||||
|
||||
def action_view_timesheet(self):
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("sale_timesheet.timesheet_action_from_sales_order")
|
||||
|
||||
@@ -79,31 +79,4 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_profitability_report_action" model="ir.actions.act_window">
|
||||
<field name="name">Project Costs and Revenues</field>
|
||||
<field name="res_model">project.profitability.report</field>
|
||||
<field name="view_mode">pivot,graph</field>
|
||||
<field name="search_view_id" ref="project_profitability_report_view_search"/>
|
||||
<field name="context">{
|
||||
'pivot_row_groupby': ['line_date:month'],
|
||||
'group_by_no_leaf':1,
|
||||
'graph_groupbys': ['project_id'],
|
||||
'group_by':[],
|
||||
'sale_show_order_product_name': 1,
|
||||
}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_empty_folder">
|
||||
No data yet!
|
||||
</p><p>
|
||||
Analyze the profitability of your projects by comparing their costs and revenues.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_project_profitability_analysis"
|
||||
parent="project.menu_project_report"
|
||||
action="project_profitability_report_action"
|
||||
name="Project Costs and Revenues"
|
||||
sequence="50"/>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
odoo.define('sale_timesheet.project_overview', function (require) {
|
||||
"use strict";
|
||||
|
||||
var qweb = require('web.qweb');
|
||||
var viewRegistry = require('web.view_registry');
|
||||
|
||||
const Controller = qweb.Controller.extend({
|
||||
events: _.extend({}, qweb.Controller.prototype.events, {
|
||||
'click .project_overview_foldable': '_onFoldToggle',
|
||||
}),
|
||||
|
||||
_onFoldToggle(ev) {
|
||||
const {model, resId} = ev.target.dataset;
|
||||
const shouldOpen = ev.target.classList.contains('fa-caret-right');
|
||||
if (model === 'sale.order' && shouldOpen) {
|
||||
this._openSaleOrder(resId);
|
||||
}
|
||||
else if (model === 'sale.order.line' && shouldOpen) {
|
||||
this._openSaleOrderLine(resId);
|
||||
}
|
||||
else {
|
||||
const targetClass = `.${model.replace(/\./g, '_')}_${resId || 'None'}`;
|
||||
this.$(targetClass).hide();
|
||||
}
|
||||
$(ev.target).toggleClass('fa-caret-right');
|
||||
$(ev.target).toggleClass('fa-caret-down');
|
||||
},
|
||||
|
||||
_openSaleOrder(id) {
|
||||
id = id || 'None';
|
||||
const targetClass = `.sale_order_${id}`;
|
||||
this.$(`.o_timesheet_forecast_sale_order_line${targetClass}`).show();
|
||||
this.$(`.o_timesheet_forecast_hr_employee${targetClass}`).hide();
|
||||
this.$(`.o_timesheet_forecast_sale_order_line${targetClass} .fa`).removeClass('fa-caret-down');
|
||||
this.$(`.o_timesheet_forecast_sale_order_line${targetClass} .fa`).addClass('fa-caret-right');
|
||||
|
||||
},
|
||||
|
||||
_openSaleOrderLine(id) {
|
||||
id = id || 'None';
|
||||
const targetClass = `.sale_order_line_${id}`;
|
||||
this.$(`.o_timesheet_forecast_hr_employee${targetClass}`).show();
|
||||
},
|
||||
});
|
||||
|
||||
var ProjectOverview = qweb.View.extend({
|
||||
withSearchBar: true,
|
||||
searchMenuTypes: ['filter', 'favorite'],
|
||||
|
||||
config: _.extend({}, qweb.View.prototype.config, {
|
||||
Controller: Controller,
|
||||
}),
|
||||
});
|
||||
|
||||
viewRegistry.add('project_overview', ProjectOverview);
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
|
||||
.o_timesheet_plan_sale_timesheet {
|
||||
|
||||
.o_title {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
[type='action'] {
|
||||
color: $o-brand-primary;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.o_timesheet_plan_stat_buttons {
|
||||
text-align: right;
|
||||
float: none;
|
||||
width: auto;
|
||||
box-shadow: unset;
|
||||
margin-left: -$o-horizontal-padding - 1;
|
||||
@include media-breakpoint-between(lg, xl, $o-extra-grid-breakpoints) {
|
||||
margin-left: (-$o-horizontal-padding*2) - 1;
|
||||
}
|
||||
> .btn.oe_stat_button {
|
||||
border-bottom: 1px solid #ced4da;
|
||||
}
|
||||
}
|
||||
|
||||
.o_profitability_wrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.o_profitability_section {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.o_timesheet_plan_sale_timesheet_dashboard {
|
||||
|
||||
.table {
|
||||
margin-top: 15px;
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.table > thead > tr > th, .table > thead > tr > td,
|
||||
.table > tbody > tr > th, .table > tbody > tr > td {
|
||||
border: none;
|
||||
}
|
||||
.table > tbody > tr > td.o_timesheet_plan_dashboard_cell {
|
||||
text-align: right;
|
||||
}
|
||||
.table > tbody > tr > td.o_timesheet_plan_dashboard_total {
|
||||
border-top: 1px solid;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.section {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.o_timesheet_plan_sale_timesheet_people_time {
|
||||
$-timesheet-progress-colors: (
|
||||
'billable_time': nth($o-colors-complete, 3),
|
||||
'billable_fixed': nth($o-colors-complete, 5),
|
||||
'non_billable_project': theme-color('primary'),
|
||||
'non_billable_timesheet': nth($o-colors-complete, 11),
|
||||
'non_billable': nth($o-colors-complete, 18),
|
||||
'canceled': $o-gray-500,
|
||||
);
|
||||
|
||||
@each $-key, $-bg in $-timesheet-progress-colors {
|
||||
.o_progress_#{$-key} {
|
||||
background-color: $-bg;
|
||||
|
||||
a {
|
||||
color: color-yiq($-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table > thead > tr > td,
|
||||
.table > tbody > tr > th, .table > tbody > tr > td {
|
||||
border: none;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.table > thead > tr > th {
|
||||
border: none;
|
||||
padding-left: 0px;;
|
||||
}
|
||||
}
|
||||
|
||||
.o_project_plan_project_timesheet_forecast {
|
||||
|
||||
margin-top: 50px;
|
||||
|
||||
th{
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
}
|
||||
th.o_right_bordered, td.o_right_bordered {
|
||||
border-right: 2px solid $o-brand-primary;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.o_timesheet_forecast_sale_order{
|
||||
font-weight: bold;
|
||||
& > td:first-child {
|
||||
color: $o-brand-primary;
|
||||
}
|
||||
}
|
||||
.o_timesheet_forecast_sale_order_line{
|
||||
td:first-child {
|
||||
padding-left: 15px;
|
||||
}
|
||||
td:first-child > a {
|
||||
margin-left: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.o_timesheet_forecast_hr_employee{
|
||||
td:first-child {
|
||||
padding-left: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_timesheet_plan_redirect {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.o_canceled_tag {
|
||||
background-color: $o-gray-500;
|
||||
color: color-yiq($o-gray-500);
|
||||
border: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,31 +6,73 @@
|
||||
</button>
|
||||
|
||||
<t t-inherit="project.ProjectRightPanel" t-inherit-mode="extension" owl="1">
|
||||
<xpath expr="//t[@t-call='project.StatButtonsSection']" position="before">
|
||||
<xpath expr="//t[@t-call='project.StatButtonsSection']" position="after">
|
||||
<t t-call="sale_timesheet.SoldSection"/>
|
||||
<t t-call="sale_timesheet.TotalSoldSection"/>
|
||||
<t t-call="sale_timesheet.ProfitabilitySection"/>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="sale_timesheet.ProfitabilitySection" owl="1">
|
||||
<t t-if="state.data.user.is_project_user && state.data.profitability_items.data.length > 0">
|
||||
<t t-set="profitability_items" t-value="state.data.profitability_items"/>
|
||||
<div class="o_rightpanel_section" name="profitability">
|
||||
<t t-name="sale_timesheet.SoldSection" owl="1">
|
||||
<t t-set="sold_items" t-value="state.data.sold_items"/>
|
||||
<t t-if="sold_items.number_sols and sold_items.allow_billable">
|
||||
<div name="sold" class="o_rightpanel_section">
|
||||
<div class="o_rightpanel_header">
|
||||
<div class="o_rightpanel_title">
|
||||
<t t-if="profitability_items.allow_billable">
|
||||
Profitability
|
||||
</t>
|
||||
<t t-else="">
|
||||
Recorded Hours
|
||||
</t>
|
||||
Sold
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_rightpanel_data">
|
||||
<div t-foreach="sold_items.data" t-as="sold_item" t-key="sold_item.name" class="o_rightpanel_data_row d-flex justify-content-between">
|
||||
<span class="o_rightpanel_left_col o_rightpanel_left_text" t-attf-class="o_color_{{sold_item.color}}"><t t-esc="sold_item.name"/></span>
|
||||
<span t-attf-class="o_rightpanel_right_col o_color_{{sold_item.color}}"><b><t t-esc="sold_item.value"/></b></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="sale_timesheet.TotalSoldSection" owl="1">
|
||||
<t t-set="sold_items" t-value="state.data.sold_items"/>
|
||||
<t t-if="sold_items.allow_billable">
|
||||
<div name="total_sold" class="o_rightpanel_section">
|
||||
<div class="o_rightpanel_header">
|
||||
<div class="o_rightpanel_title">
|
||||
<span class="o_rightpanel_left_col o_rightpanel_left_text"> Total Sold </span>
|
||||
</div>
|
||||
<span class="o_rightpanel_right_col font-weight-bold float-right"><t t-esc="sold_items.total_sold"/> <t t-esc="sold_items.company_unit_name"/></span>
|
||||
</div>
|
||||
<div class="o_rightpanel_data">
|
||||
<div class="o_rightpanel_data_row">
|
||||
<span class="o_rightpanel_left_col o_rightpanel_left_text">Effective</span>
|
||||
<span class="o_rightpanel_right_col"><t t-esc="sold_items.effective_sold"/> <t t-esc="sold_items.company_unit_name"/></span>
|
||||
</div>
|
||||
<div class="o_rightpanel_data_row" t-if="sold_items.planned_sold > 0 and sold_items.allow_forecast">
|
||||
<span class="o_rightpanel_left_col o_rightpanel_left_text">Planned</span>
|
||||
<span class="o_rightpanel_right_col"><t t-esc="sold_items.planned_sold"/> <t t-esc="sold_items.company_unit_name"/></span>
|
||||
</div>
|
||||
<div class="o_rightpanel_data_row">
|
||||
<span class="o_rightpanel_left_col o_rightpanel_left_text">Remaining</span>
|
||||
<span class="o_rightpanel_right_col" t-attf-class="o_color_{{sold_items.remaining.color}}"><t t-esc="sold_items.remaining.value"/> <t t-esc="sold_items.company_unit_name"/></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="sale_timesheet.ProfitabilitySection" owl="1">
|
||||
<<<<<<< HEAD
|
||||
<t t-if="state.data.user.is_project_user && state.data.profitability_items.data.length > 0">
|
||||
=======
|
||||
<t t-if="state.data.user.is_project_manager && state.data.analytic_account_id && state.data.profitability_items.data.length > 0">
|
||||
>>>>>>> [IMP] sale_timesheet: remove overview and improve updates
|
||||
<t t-set="profitability_items" t-value="state.data.profitability_items"/>
|
||||
<div name="profitability" class="o_rightpanel_section">
|
||||
<div class="o_rightpanel_header">
|
||||
<div class="o_rightpanel_title">
|
||||
Profitability
|
||||
</div>
|
||||
<span class="o_rightpanel_button" t-if="profitability_items.action">
|
||||
<a t-on-click="onProjectActionClick"
|
||||
t-att-data-action="profitability_items.action"
|
||||
data-type="object">
|
||||
See Overview
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="o_rightpanel_data">
|
||||
|
||||
@@ -247,29 +247,21 @@ tour.register('sale_timesheet_tour', {
|
||||
trigger: ".o_project_updates_breadcrumb",
|
||||
content: 'Open Updates'
|
||||
}, {
|
||||
trigger: ".o_rightpanel_button a:contains('See Overview')",
|
||||
content: 'See Overview'
|
||||
trigger: ".o_rightpanel_title:eq(0):contains('Sold')",
|
||||
content: 'Check the user sees Sold section'
|
||||
}, {
|
||||
trigger: ".breadcrumb :contains('Overview')",
|
||||
content: 'Check the user sees Overview'
|
||||
}, {
|
||||
trigger: ".o_back_button"
|
||||
},{
|
||||
trigger: ".o_rightpanel_left_text:eq(0):contains('Timesheets')",
|
||||
content: 'Check the user sees Timesheet stat',
|
||||
trigger: ".o_rightpanel_left_text:eq(0):contains('Prepaid Hours')",
|
||||
content: 'Check the user sees Sold subsection',
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_rightpanel_title:eq(1):contains('Total Sold')",
|
||||
content: 'Check the user sees Total Sold section'
|
||||
}, {
|
||||
trigger: ".o_rightpanel_left_text:eq(1):contains('Revenues')",
|
||||
content: 'Check the user sees Revenues stat',
|
||||
run: function () {},
|
||||
trigger: ".o_rightpanel_title:eq(2):contains('Profitability')",
|
||||
content: 'Check the user sees Profitability section'
|
||||
}, {
|
||||
trigger: ".o_rightpanel_left_text:eq(2):contains('Costs')",
|
||||
content: 'Check the user sees Cost stat',
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_rightpanel_left_text:eq(3):contains('Margin')",
|
||||
content: 'Check the user sees Margin stat',
|
||||
run: function () {},
|
||||
trigger: ".o_rightpanel_title:eq(3):contains('Milestones')",
|
||||
content: 'Check the user sees Milestones section'
|
||||
}, {
|
||||
trigger: ".o-kanban-button-new"
|
||||
}, {
|
||||
@@ -277,22 +269,6 @@ tour.register('sale_timesheet_tour', {
|
||||
run: 'text New update'
|
||||
}, {
|
||||
trigger: ".o_form_button_save"
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=description] :contains('Profitability')",
|
||||
content: 'Check the user sees Profitability section',
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=description] :contains('billing rate')",
|
||||
content: 'Check the user sees Profitability section content',
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=description] :contains('People')",
|
||||
content: 'Check the user sees People section',
|
||||
run: function () {},
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=description] :contains('(new this month)')",
|
||||
content: 'Check the user sees People section content',
|
||||
run: function () {},
|
||||
}]);
|
||||
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ from . import test_sale_service
|
||||
from . import test_project_billing
|
||||
from . import test_reinvoice
|
||||
from . import test_reporting
|
||||
from . import test_project_overview
|
||||
from . import test_project_billing_multicompany
|
||||
from . import test_upsell_warning
|
||||
from . import test_edit_so_line_timesheet
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.sale_timesheet.tests.test_reporting import TestReporting
|
||||
from odoo.tools import float_compare
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install')
|
||||
class TestSaleProject(TestReporting):
|
||||
|
||||
def test_project_overview_by_project(self):
|
||||
rounding = self.env.company.currency_id.rounding
|
||||
|
||||
so_line_deliver_global_project = self.env['sale.order.line'].create({
|
||||
'name': self.product_delivery_timesheet2.name,
|
||||
'product_id': self.product_delivery_timesheet2.id,
|
||||
'product_uom_qty': 50,
|
||||
'product_uom': self.product_delivery_timesheet2.uom_id.id,
|
||||
'price_unit': self.product_delivery_timesheet2.list_price,
|
||||
'order_id': self.sale_order_2.id,
|
||||
})
|
||||
so_line_deliver_no_task = self.env['sale.order.line'].create({
|
||||
'name': self.product_delivery_manual1.name,
|
||||
'product_id': self.product_delivery_manual1.id,
|
||||
'product_uom_qty': 50,
|
||||
'product_uom': self.product_delivery_manual1.uom_id.id,
|
||||
'price_unit': self.product_delivery_manual1.list_price,
|
||||
'order_id': self.sale_order_2.id,
|
||||
})
|
||||
so_line_deliver_no_task.write({'qty_delivered': 1.0})
|
||||
|
||||
self.sale_order_2.action_confirm()
|
||||
project_so = self.so_line_order_project.project_id
|
||||
# log timesheet for billable time
|
||||
timesheet1 = self._log_timesheet_manager(project_so, 10, so_line_deliver_global_project.task_id)
|
||||
|
||||
task_so = self.so_line_order_project.task_id
|
||||
# logged some timesheets: on project only, then on tasks with different employees
|
||||
timesheet2 = self._log_timesheet_user(project_so, 2)
|
||||
timesheet3 = self._log_timesheet_user(project_so, 3, task_so)
|
||||
timesheet4 = self._log_timesheet_manager(project_so, 1, task_so)
|
||||
|
||||
# create a task which is not linked to sales order and fill non-billable timesheet
|
||||
task = self.env['project.task'].create({
|
||||
'name': 'Task',
|
||||
'project_id': project_so.id,
|
||||
'allow_billable': False,
|
||||
'sale_line_id': False
|
||||
})
|
||||
timesheet5 = self._log_timesheet_user(project_so, 5, task)
|
||||
|
||||
# invoice the Sales Order SO2
|
||||
context = {
|
||||
"active_model": 'sale.order',
|
||||
"active_ids": [self.sale_order_2.id],
|
||||
"active_id": self.sale_order_2.id,
|
||||
'open_invoices': True,
|
||||
}
|
||||
|
||||
payment = self.env['sale.advance.payment.inv'].create({
|
||||
'advance_payment_method': 'delivered',
|
||||
})
|
||||
|
||||
action_invoice = payment.with_context(context).create_invoices()
|
||||
invoice = self.env['account.move'].browse(action_invoice['res_id'])
|
||||
invoice.action_post()
|
||||
|
||||
# simulate the auto creation of the SO line for expense, like we confirm a vendor bill.
|
||||
so_line_expense = self.env['sale.order.line'].create({
|
||||
'name': self.product_expense.name,
|
||||
'product_id': self.product_expense.id,
|
||||
'product_uom_qty': 0.0,
|
||||
'product_uom': self.product_expense.uom_id.id,
|
||||
'price_unit': self.product_expense.list_price, # reinvoice at sales price
|
||||
'order_id': self.sale_order_2.id,
|
||||
'is_expense': True,
|
||||
})
|
||||
|
||||
expense = self.env['account.analytic.line'].create({
|
||||
'name': 'expense on project_so',
|
||||
'account_id': project_so.analytic_account_id.id,
|
||||
'so_line': so_line_expense.id,
|
||||
'employee_id': self.employee_user.id,
|
||||
'unit_amount': 4,
|
||||
'amount': 4 * self.product_expense.list_price * -1,
|
||||
'product_id': self.product_expense.id,
|
||||
'product_uom_id': self.product_expense.uom_id.id,
|
||||
})
|
||||
|
||||
other_revenues = self.env['account.analytic.line'].create({
|
||||
'name': 'pther revenues on project_so',
|
||||
'account_id': project_so.analytic_account_id.id,
|
||||
'employee_id': self.employee_user.id,
|
||||
'unit_amount': 1,
|
||||
'amount': self.product_expense.list_price,
|
||||
'product_id': self.product_expense.id,
|
||||
'product_uom_id': self.product_expense.uom_id.id,
|
||||
})
|
||||
|
||||
view_id = self.env.ref('sale_timesheet.project_timesheet_action_client_timesheet_plan').id
|
||||
vals = self.env['project.project']._qweb_prepare_qcontext(view_id, [['id', '=', project_so.id]])
|
||||
|
||||
dashboard_value = timesheet2.unit_amount + timesheet3.unit_amount + timesheet4.unit_amount + timesheet5.unit_amount + timesheet1.unit_amount
|
||||
project_so_timesheet_sold_unit = timesheet3.unit_amount + timesheet4.unit_amount
|
||||
project_rate_non_billable = timesheet5.unit_amount / dashboard_value * 100
|
||||
project_rate_non_billable_project = timesheet2.unit_amount / dashboard_value * 100
|
||||
project_rate_billable_time = timesheet1.unit_amount / dashboard_value * 100
|
||||
project_rate_billable_fixed = project_so_timesheet_sold_unit / dashboard_value * 100
|
||||
project_rate_total = project_rate_non_billable + project_rate_non_billable_project + project_rate_billable_time + project_rate_billable_fixed
|
||||
project_invoiced = self.so_line_order_project.price_unit * self.so_line_order_project.product_uom_qty * timesheet1.unit_amount
|
||||
project_timesheet_cost = timesheet2.amount + timesheet3.amount + timesheet4.amount + timesheet5.amount + timesheet1.amount
|
||||
project_other_revenues = invoice.invoice_line_ids.search([('product_id', '=', self.product_delivery_manual1.id)])
|
||||
|
||||
self.assertEqual(float_compare(vals['dashboard']['time']['non_billable'], timesheet5.unit_amount, precision_rounding=rounding), 0, "The hours non-billable should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['time']['non_billable_project'], timesheet2.unit_amount, precision_rounding=rounding), 0, "The hours non-billable-project should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['time']['billable_time'], timesheet1.unit_amount, precision_rounding=rounding), 0, "The hours billable-time should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['time']['billable_fixed'], project_so_timesheet_sold_unit, precision_rounding=rounding), 0, "The hours billable-fixed should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['time']['total'], dashboard_value, precision_rounding=rounding), 0, "The total hours should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['rates']['non_billable'], project_rate_non_billable, precision_rounding=rounding), 0, "The rate non-billable should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['rates']['non_billable_project'], project_rate_non_billable_project, precision_rounding=rounding), 0, "The rate non-billable-project should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['rates']['billable_time'], project_rate_billable_time, precision_rounding=rounding), 0, "The rate billable-time should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['rates']['billable_fixed'], project_rate_billable_fixed, precision_rounding=rounding), 0, "The rate billable-fixed should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['rates']['total'], project_rate_total, precision_rounding=rounding), 0, "The total rates should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['profit']['invoiced'], project_invoiced, precision_rounding=rounding), 0, "The amount invoiced should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['profit']['cost'], project_timesheet_cost, precision_rounding=rounding), 0, "The amount cost should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['profit']['expense_cost'], expense.amount, precision_rounding=rounding), 0, "The amount expense-cost should be the one from the SO2 line, as we are in ordered quantity")
|
||||
self.assertEqual(float_compare(vals['dashboard']['profit']['other_revenues'], other_revenues.amount + project_other_revenues.price_total, precision_rounding=rounding), 0, "The amount of the other revenues should be equal to the corresponding account move line and the one from the SO line")
|
||||
self.assertEqual(float_compare(vals['dashboard']['profit']['total'], project_invoiced + project_timesheet_cost + expense.amount + other_revenues.amount + project_other_revenues.price_total, precision_rounding=rounding), 0, "The total amount should be the sum of the SO2 line and the created other_revenues account analytic line")
|
||||
self.assertEqual(float_compare(vals['repartition_employee_max'], 11.0, precision_rounding=rounding), 0, "The amount of repartition-employee-max should be the one from SO2 line")
|
||||
@@ -1,124 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from datetime import date
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from freezegun import freeze_time
|
||||
|
||||
from odoo import fields
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import Form
|
||||
from odoo.tools import format_date
|
||||
|
||||
from odoo.addons.project.tests.test_project_update_flow import TestProjectUpdate
|
||||
|
||||
@tagged('-at_install', 'post_install')
|
||||
class TestProjectUpdateSaleTimesheet(TestProjectUpdate):
|
||||
|
||||
def test_project_update_form_profitability(self):
|
||||
try:
|
||||
with Form(self.env['project.update'].with_context({'default_project_id': self.project_pigs.id})) as update_form:
|
||||
update_form.name = "Test"
|
||||
update_form.progress = 65
|
||||
update = update_form.save()
|
||||
except Exception as e:
|
||||
raise AssertionError("Error raised unexpectedly while filling the project update form ! Exception : " + e.args[0])
|
||||
|
||||
self.assertTrue("Profitability" in update.description, "The description should contain 'Profitability'.")
|
||||
|
||||
def test_project_update_description_profitability(self):
|
||||
self.project_pigs.allow_billable = True
|
||||
template_values = self.env['project.update']._get_template_values(self.project_pigs)
|
||||
today = fields.Date.context_today(self.project_pigs)
|
||||
self.assertEqual(template_values['profitability']['month'], format_date(self.env, today, date_format='LLLL y'),
|
||||
"The month used in the template should be well defined")
|
||||
self.assertEqual(template_values['profitability']['previous_month'], format_date(self.env, (today + relativedelta(months=-1)), date_format='LLLL'),
|
||||
"The previous month used in the template should be well defined")
|
||||
self.assertTrue(template_values['profitability']['is_timesheet_uom_hour'], "Default timesheet uom is hour.")
|
||||
self.assertEqual(template_values['profitability']['timesheet_uom'], "hours", "Default timesheet uom should be displayed as 'hours'")
|
||||
self.assertEqual(template_values['profitability']['timesheet_unit_amount'], '0', "Timesheet unit amount used in the template should be defined.")
|
||||
self.assertEqual(template_values['profitability']['previous_timesheet_unit_amount'], '0', "Previous Timesheet unit amount used in the template should be defined.")
|
||||
self.assertEqual(template_values['profitability']['timesheet_trend'], "0", "Timesheet trend used in the template should be well defined.")
|
||||
self.assertEqual(template_values['profitability']['costs'], "$\xa00.00", "Project costs used in the template should be well defined")
|
||||
self.assertEqual(template_values['profitability']['revenues'], "$\xa00.00", "Project revenues used in the template should be well defined")
|
||||
self.assertEqual(template_values['profitability']['margin'], 0, "Margin used in the template should be well defined")
|
||||
self.assertEqual(template_values['profitability']['margin_formatted'], "$\xa00.00", "Margin formatted used in the template should be well defined")
|
||||
self.assertEqual(template_values['profitability']['margin_percentage'], "0", "Margin percentage used in the template should be well defined")
|
||||
self.assertEqual(template_values['profitability']['billing_rate'], "0", "Billing rate used in the template should be well defined")
|
||||
|
||||
def test_project_update_panel_profitability_no_billable(self):
|
||||
panel_data = self.project_pigs.get_panel_data()
|
||||
self.assertEqual(len(panel_data['profitability_items']['data']), 1, "Panel data should contain 'profitability_items>data' entries")
|
||||
self.assertEqual(panel_data['profitability_items']['allow_billable'], False, "Project should not be billable by default")
|
||||
try:
|
||||
self.project_pigs.action_view_timesheet()
|
||||
except Exception as e:
|
||||
raise AssertionError("Error raised unexpectedly while calling the action defined in profitalities action panel data ! Exception : " + e.args[0])
|
||||
|
||||
def test_project_update_panel_profitability_no_timesheet(self):
|
||||
self.project_pigs.allow_billable = True
|
||||
panel_data = self.project_pigs.get_panel_data()
|
||||
|
||||
self.assertEqual(len(panel_data['profitability_items']['data']), 4, "Panel data should contain 'profitability_items>data' entries")
|
||||
self.assertEqual(panel_data['profitability_items']['action'], "action_view_timesheet", "Panel data should contain 'profitability_items>action' entry")
|
||||
|
||||
try:
|
||||
self.project_pigs.action_view_timesheet()
|
||||
except Exception as e:
|
||||
raise AssertionError("Error raised unexpectedly while calling the action defined in profitalities action panel data ! Exception : " + e.args[0])
|
||||
|
||||
def test_project_update_description_date(self):
|
||||
self.project_pigs.allow_timesheets = True
|
||||
self.project_pigs.allow_billable = True
|
||||
self.employee_user = self.env['hr.employee'].create({
|
||||
'name': 'Employee User',
|
||||
'timesheet_cost': 15,
|
||||
})
|
||||
self.env['account.analytic.line'].create({
|
||||
'project_id': self.project_pigs.id,
|
||||
'task_id': self.task_1.id,
|
||||
'name': 'my first timesheet',
|
||||
'unit_amount': 4,
|
||||
'employee_id': self.employee_user.id,
|
||||
'date': '2021-07-23'
|
||||
})
|
||||
self.env['account.analytic.line'].create({
|
||||
'project_id': self.project_pigs.id,
|
||||
'task_id': self.task_1.id,
|
||||
'name': 'my second timesheet',
|
||||
'unit_amount': 6,
|
||||
'employee_id': self.employee_user.id,
|
||||
'date': '2021-08-23'
|
||||
})
|
||||
with freeze_time('2021-08-24'):
|
||||
template_values = self.env['project.update']._get_template_values(self.project_pigs)
|
||||
self.assertEqual(template_values['profitability']['timesheet_unit_amount'], '6', "Timesheet unit amount used in the template should be defined.")
|
||||
self.assertEqual(template_values['profitability']['previous_timesheet_unit_amount'], '4', "Previous Timesheet unit amount used in the template should be defined.")
|
||||
|
||||
def test_project_update_description_date_lang(self):
|
||||
self.project_pigs.allow_timesheets = True
|
||||
self.project_pigs.allow_billable = True
|
||||
self.env['res.lang']._activate_lang('fr_FR')
|
||||
self.env.user.write({'lang': 'fr_FR'})
|
||||
self.employee_user = self.env['hr.employee'].create({
|
||||
'name': 'Employee User',
|
||||
'timesheet_cost': 15,
|
||||
})
|
||||
self.env['account.analytic.line'].create({
|
||||
'project_id': self.project_pigs.id,
|
||||
'task_id': self.task_1.id,
|
||||
'name': 'my first timesheet',
|
||||
'unit_amount': 4,
|
||||
'employee_id': self.employee_user.id,
|
||||
'date': '2021-07-23'
|
||||
})
|
||||
self.env['account.analytic.line'].create({
|
||||
'project_id': self.project_pigs.id,
|
||||
'task_id': self.task_1.id,
|
||||
'name': 'my second timesheet',
|
||||
'unit_amount': 6,
|
||||
'employee_id': self.employee_user.id,
|
||||
'date': '2021-08-23'
|
||||
})
|
||||
with freeze_time('2021-08-24'):
|
||||
template_values = self.env['project.update']._get_template_values(self.project_pigs)
|
||||
self.assertEqual(template_values['profitability']['timesheet_unit_amount'], '6', "Timesheet unit amount used in the template should be defined.")
|
||||
self.assertEqual(template_values['profitability']['previous_timesheet_unit_amount'], '4', "Previous Timesheet unit amount used in the template should be defined.")
|
||||
|
||||
@@ -95,7 +95,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
|
||||
'employee_id': self.employee_user.id,
|
||||
})
|
||||
self.assertEqual(so_line_ordered_project_only.qty_delivered, 0.0, 'Timesheet directly on project does not increase delivered quantity on so line')
|
||||
self.assertEqual(timesheet3.timesheet_invoice_type, 'non_billable_project', "Timesheets without task shoulbe be 'no project found'")
|
||||
self.assertEqual(timesheet3.timesheet_invoice_type, 'non_billable', "Timesheets without SO should be be 'non-billable'")
|
||||
self.assertFalse(timesheet3.timesheet_invoice_id, "The timesheet should not be linked to the invoice, since we are in ordered quantity")
|
||||
|
||||
# log timesheet on task in global project (higher than the initial ordrered qty)
|
||||
@@ -270,7 +270,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
|
||||
})
|
||||
self.assertTrue(float_is_zero(so_line_deliver_only_project.qty_delivered, precision_digits=2), "Timesheeting on project should not incremented the delivered quantity on the SO line")
|
||||
self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should have quantity to invoice')
|
||||
self.assertEqual(timesheet3.timesheet_invoice_type, 'non_billable_project', "Timesheets without task shoulbe be 'no project found'")
|
||||
self.assertEqual(timesheet3.timesheet_invoice_type, 'billable_time', "Timesheets with an amount > 0 should be 'billable time'")
|
||||
self.assertFalse(timesheet3.timesheet_invoice_id, "The timesheet3 should not be linked to the invoice yet")
|
||||
|
||||
# let's log some timesheets on the task (new task/new project)
|
||||
@@ -357,7 +357,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheet):
|
||||
self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" should not need to be invoiced on so confirmation')
|
||||
|
||||
self.assertEqual(timesheet1.timesheet_invoice_type, 'billable_fixed', "Timesheets linked to SO line with ordered product shoulbe be billable fixed since it is a milestone")
|
||||
self.assertEqual(timesheet2.timesheet_invoice_type, 'non_billable_project', "Timesheets without task shoulbe be 'no project found'")
|
||||
self.assertEqual(timesheet2.timesheet_invoice_type, 'non_billable', "Timesheets without SO should be be 'non-billable'")
|
||||
self.assertFalse(timesheet1.timesheet_invoice_id, "The timesheet1 should not be linked to the invoice")
|
||||
self.assertFalse(timesheet2.timesheet_invoice_id, "The timesheet2 should not be linked to the invoice")
|
||||
|
||||
|
||||
@@ -1,486 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="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 d-flex justify-content-end flex-row flex-wrap">
|
||||
<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" name="profitability_title_block">
|
||||
<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" name="table_hours_recorded">
|
||||
<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 name="row_billed_on_timesheets">
|
||||
<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 name="row_billed_fix_price">
|
||||
<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" name="row_non_billable_tasks_timesheets">
|
||||
<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 name="row_non_billable_tasks">
|
||||
<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" name="row_non_billable_timesheets">
|
||||
<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" name="row_cancelled_timesheets">
|
||||
<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" name="table_profitability">
|
||||
<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 name="row_invoiced">
|
||||
<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 name="row_to_invoice">
|
||||
<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" name="row_other_revenue">
|
||||
<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 name="row_timesheet_costs">
|
||||
<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" name="row_other_costs">
|
||||
<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)" name="row_reinvoiced_costs">
|
||||
<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 name="row_profitability_total">
|
||||
<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" name="time_by_people_title_block">
|
||||
<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" name="table_time_overview">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Employee</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 h3 mb-3">
|
||||
<span class="badge badge-pill border-0 px-3 py-2 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 border-0 px-3 py-2 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 border-0 px-3 py-2 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 border-0 px-3 py-2 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 border-0 px-3 py-2 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 border-0 px-3 py-2 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 name="row_time_by_people">
|
||||
<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=avatar_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 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 overflow-hidden">
|
||||
<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" name="timesheets_title_block">
|
||||
<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" name="table_timesheets">
|
||||
<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'">
|
||||
<t t-if="env.user.has_group('sales_team.group_sale_salesman')">
|
||||
<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>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-esc="row_value.get('label')"/>
|
||||
</t>
|
||||
<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>
|
||||
|
||||
<template id="progressbar" name="project overview progressbar segments">
|
||||
<t t-set="amount" t-value="employee[key]"/>
|
||||
<t t-if="amount > 0">
|
||||
<t t-set="title">
|
||||
<t t-esc="label"/>: <t t-if="is_uom_day" t-esc="amount" t-options="{'widget': 'timesheet_uom'}"/><t t-else="" t-esc="amount" t-options="{'widget': 'float_time'}"/>
|
||||
</t>
|
||||
<a t-attf-class="progress-bar o_progress_{{key}}"
|
||||
t-attf-style="width: {{amount / total * 100}}%"
|
||||
type="action" data-model="account.analytic.line"
|
||||
t-att-data-domain="employee['__domain_' + key]"
|
||||
>
|
||||
<span t-att-title="title" style="font-size: 0px; width: 100%; height: 100%;">
|
||||
<t t-esc="label" />
|
||||
</span>
|
||||
</a>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -6,12 +6,23 @@
|
||||
<field name="model">project.project</field>
|
||||
<field name="inherit_id" ref="hr_timesheet.project_invoice_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//button[@name='%(project.action_project_task_burndown_chart_report)d']" position="after">
|
||||
<button string="Costs / Revenues" class="oe_stat_button" type="object" name="action_view_timesheet" icon="fa-puzzle-piece" attrs="{'invisible': [('allow_billable', '=', False)]}" groups="project.group_project_manager"/>
|
||||
</xpath>
|
||||
<xpath expr="//button[@name='action_view_so']" position="attributes">
|
||||
<attribute name="attrs">{'invisible': ['|', ('allow_billable', '=', False), ('sale_order_id', '=', False)]}</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//button[@name='%(project.action_project_task_burndown_chart_report)d']" position="after">
|
||||
<button name="action_billable_time_button" type="object" class="oe_stat_button" icon="fa-clock-o" attrs="{'invisible': ['|', ('allow_timesheets', '=', False), ('analytic_account_id', '=', False)]}" groups="hr_timesheet.group_hr_timesheet_approver">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<div class="oe_inline">
|
||||
<span class="o_stat_value mr-1">
|
||||
<field name="billable_percentage" widget="statinfo" nolabel="1"/>%
|
||||
</span>
|
||||
</div>
|
||||
<span class="o_stat_text">
|
||||
Billable Time
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</xpath>
|
||||
<xpath expr="//header" position="inside">
|
||||
<button name="action_make_billable" string="Create Sales Order" type="object" attrs="{'invisible': [('display_create_order', '=', False)]}" groups="sales_team.group_sale_salesman"/>
|
||||
</xpath>
|
||||
@@ -92,7 +103,7 @@
|
||||
<field name="pricing_type" invisible="1"/>
|
||||
</xpath>
|
||||
<xpath expr="//div[hasclass('o_kanban_manage_reporting')]" position="inside">
|
||||
<div role="menuitem" groups="project.group_project_manager">
|
||||
<div role="menuitem" t-if="record.rating_active.raw_value" groups="project.group_project_manager">
|
||||
<a name="action_view_all_rating" type="object" context="{'search_default_rating_last_30_days':1}">
|
||||
Customer Ratings
|
||||
</a>
|
||||
@@ -195,10 +206,4 @@
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_timesheet_action_client_timesheet_plan" model="ir.actions.act_window">
|
||||
<field name="name">Overview</field>
|
||||
<field name="res_model">project.project</field>
|
||||
<field name="view_mode">qweb</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
<template id="project_update_default_description" inherit_id="hr_timesheet.project_update_default_description">
|
||||
<template id="project_update_default_description" inherit_id="project.project_update_default_description">
|
||||
<!--As this template is rendered in an html field, the spaces may be interpreted as nbsp while editing. -->
|
||||
<xpath expr="//div[@name='milestone']" position="after">
|
||||
<hr t-if="profitability"/>
|
||||
<xpath expr="//div[@name='milestone']" position="before">
|
||||
<br/>
|
||||
<div t-if="project.allow_billable and 'data' in services and len(services['data']) > 0">
|
||||
<h3><b><u>Sold</u></b></h3>
|
||||
<table class="table table-bordered">
|
||||
<tbody>
|
||||
<thead>
|
||||
<td>Service</td>
|
||||
<td>Sold</td>
|
||||
<td>Effective</td>
|
||||
<td>Remaining</td>
|
||||
</thead>
|
||||
<tr t-foreach="services['data']" t-as="service">
|
||||
<td><t t-esc="service['name']"/></td>
|
||||
<td><t t-esc="service['sold_value']"/> <t t-esc="service['uom']"/></td>
|
||||
<td><t t-esc="service['effective_value']"/> <t t-esc="service['uom']"/></td>
|
||||
<td><t t-esc="service['remaining_value']"/> <t t-esc="service['uom']"/></td>
|
||||
</tr>
|
||||
<tfoot>
|
||||
<td>Total</td>
|
||||
<td><t t-esc="services['total_sold']"/> Hours</td>
|
||||
<td><t t-esc="services['total_effective']"/> Hours</td>
|
||||
<td><t t-esc="services['total_remaining']"/> Hours</td>
|
||||
</tfoot>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div name="profitability" t-if="profitability">
|
||||
<h1>Profitability</h1>
|
||||
|
||||
<t t-if="profitability['allow_timesheets']">
|
||||
In <t t-esc="profitability['month']"/>, <b><t t-if="profitability['is_timesheet_uom_hour']" t-esc="profitability['timesheet_unit_amount']"/><t t-else="" t-esc="profitability['timesheet_unit_amount']"/> <t t-esc="profitability['timesheet_uom']"/></b> have been recorded on the project vs.
|
||||
<t t-if="profitability['is_timesheet_uom_hour']" t-esc="profitability['previous_timesheet_unit_amount']"/><t t-else="" t-esc="profitability['previous_timesheet_unit_amount']"/>
|
||||
<t t-esc="profitability['timesheet_uom']"/> in <t t-esc="profitability['previous_month']"/> (<t t-esc="profitability['timesheet_trend']"/> %).
|
||||
</t>
|
||||
|
||||
<t t-if="profitability['allow_costs_and_revenues']">
|
||||
<t t-if="project.analytic_account_id and project.allow_billable and user.has_group('project.group_project_manager')" name="costs">
|
||||
<h3><b><u>Profitability</u></b></h3>
|
||||
The cost of the project is now at <t t-esc="profitability['costs']"/>, for a revenue of <t t-esc="profitability['revenues']"/>, leading to a
|
||||
<span>
|
||||
<font t-if="profitability['margin'] > 0" style="color: rgb(0, 128, 0)">
|
||||
@@ -23,19 +42,10 @@ The cost of the project is now at <t t-esc="profitability['costs']"/>, for a rev
|
||||
<b><t t-esc="profitability['margin_formatted']"/></b>
|
||||
</font>
|
||||
<t t-else="" t-esc="profitability['margin_formatted']"/>
|
||||
</span> margin (<t t-esc="profitability['margin_percentage']"/> %), and a <b><t t-esc="profitability['billing_rate']"/> % billing rate</b>.
|
||||
</span> margin (<t t-esc="profitability['margin_percentage']"/>%).
|
||||
</t>
|
||||
</div>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="project_profitability_timesheet_panel" name="Timesheet Profitability">
|
||||
<t t-if="is_timesheet_uom_hour" t-esc="timesheet_unit_amount" t-options='{"widget": "float_time"}'/>
|
||||
<t t-else="" t-esc="timesheet_unit_amount"/>
|
||||
<t t-if="not is_timesheet_uom_hour">
|
||||
<t t-esc="timesheet_uom"/>
|
||||
</t>
|
||||
(<t t-esc="percentage_billable"/> % billable)
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
|
||||
Reference in New Issue
Block a user