[MERGE] sale_timesheet: revenue and overview

These commit are the 3rd part of improvements
of Services flows.

It introduces 2 important parts
- timesheet revenue: how much the timesheet bring back
money.
- timesheet overview: a kind of dashboad to see the financial
state of a project, grouped by employee or billable type

Other improvements are UX, adding missing settings, hide
some technical fields, ... to ease the configuration and
day-to-day usage.
This commit is contained in:
Jérome Maes
2017-05-04 16:35:01 +02:00
20 changed files with 954 additions and 18 deletions
+1 -1
View File
@@ -20,7 +20,7 @@
<field name="arch" type="xml">
<xpath expr="//group[@name='active_group']" position="before">
<!-- Empty invisible group to be filled by timesheet_grid and sale_timesheet -->
<group string="Timesheets" name="timesheet" invisible="1">
<group string="Timesheets" name="timesheet" invisible="1" groups="hr_timesheet.group_timesheet_manager">
</group>
</xpath>
<xpath expr="//button[@name='toggle_active']" position="before">
@@ -113,6 +113,11 @@
<div class="text-muted">
Sell services and invoice time spent
</div>
<div class="content-group" name="msg_module_sale_timesheet">
<div class="text-warning mt16" attrs="{'invisible': [('module_sale_timesheet', '=', False)]}">
Save this page and come back here to set up the feature.
</div>
</div>
</div>
</div>
<div class="col-xs-12 col-md-6 o_setting_box">
@@ -9,8 +9,8 @@
<xpath expr="//group[@name='description']" position="after">
<group name="timesheet" string="Timesheet">
<field name="timesheet_generate"/>
<field name="timesheet_project_id" attrs="{'invisible': [('timesheet_generate', '!=', True)], 'required': [('timesheet_generate', '=', True)]}" context="{'active_test': False}"/>
<field name="timesheet_task_id" attrs="{'invisible': [('timesheet_generate', '!=', True)], 'required': [('timesheet_generate', '=', True)]}" context="{'active_test': False}"/>
<field name="timesheet_project_id" attrs="{'invisible': [('timesheet_generate', '!=', True)], 'required': [('timesheet_generate', '=', True)]}" context="{'active_test': False}" groups="base.group_no_one"/>
<field name="timesheet_task_id" attrs="{'invisible': [('timesheet_generate', '!=', True)], 'required': [('timesheet_generate', '=', True)]}" context="{'active_test': False}" groups="base.group_no_one"/>
</group>
</xpath>
</field>
+7 -7
View File
@@ -829,19 +829,19 @@ class SaleOrderLine(models.Model):
@api.multi
def invoice_line_create(self, invoice_id, qty):
""" Create an invoice line. The quantity to invoice can be positive (invoice) or negative (refund).
:param invoice_id: integer
:param qty: float quantity to invoice
:returns recordset of account.invoice.line created
"""
Create an invoice line. The quantity to invoice can be positive (invoice) or negative
(refund).
:param invoice_id: integer
:param qty: float quantity to invoice
"""
invoice_lines = self.env['account.invoice.line']
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
for line in self:
if not float_is_zero(qty, precision_digits=precision):
vals = line._prepare_invoice_line(qty=qty)
vals.update({'invoice_id': invoice_id, 'sale_line_ids': [(6, 0, [line.id])]})
self.env['account.invoice.line'].create(vals)
invoice_lines |= self.env['account.invoice.line'].create(vals)
return invoice_lines
@api.multi
def _get_display_price(self, product):
+1
View File
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
from . import controllers
+4
View File
@@ -17,11 +17,15 @@ have real delivered quantities in sales orders.
'depends': ['sale', 'hr_timesheet'],
'data': [
'data/sale_timesheet_data.xml',
'views/account_invoice_views.xml',
'views/sale_order_views.xml',
'views/product_views.xml',
'views/hr_views.xml',
'views/project_task_views.xml',
'views/procurement_views.xml',
'views/hr_timesheet_views.xml',
'views/res_config_views.xml',
'views/hr_timesheet_templates.xml',
],
'demo': [
'data/sale_service_demo.xml',
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
+86
View File
@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo.http import request
from odoo.tools import float_round
class SaleTimesheetController(http.Controller):
@http.route('/timesheet/plan', type='json', auth="user")
def plan(self, domain):
values = self._prepare_plan_values(domain)
view = request.env['ir.model.data'].get_object('sale_timesheet', 'timesheet_plan')
return {
'html_content': view.render(values)
}
def _prepare_plan_values(self, domain):
values = {
'currency': request.env.user.company_id.currency_id,
'timesheet_lines': request.env['account.analytic.line'].search(domain),
'domain': domain,
}
hour_rounding = request.env.ref('product.product_uom_hour').rounding
billable_types = ['non_billable', 'billable_time', 'billable_fixed']
# -- Dashboard (per billable type)
dashboard_values = {
'hours': dict.fromkeys(billable_types + ['total'], 0.0),
'rates': dict.fromkeys(billable_types + ['total'], 0.0),
'money_amount': {
'invoiced': 0.0,
'to_invoiced': 0.0,
'cost': 0.0,
'total': 0.0,
}
}
dashboard_domain = domain + [('timesheet_invoice_type', '!=', False)] # force billable type
dashboard_data = request.env['account.analytic.line'].read_group(dashboard_domain, ['unit_amount', 'timesheet_revenue', 'timesheet_invoice_type'], ['timesheet_invoice_type'])
dashboard_total_hours = sum([data['unit_amount'] for data in dashboard_data])
for data in dashboard_data:
billable_type = data['timesheet_invoice_type']
# hours
dashboard_values['hours'][billable_type] = float_round(data.get('unit_amount'), precision_rounding=hour_rounding)
dashboard_values['hours']['total'] += float_round(data.get('unit_amount'), precision_rounding=hour_rounding)
# rates
dashboard_values['rates'][billable_type] = float_round(data.get('unit_amount') / dashboard_total_hours * 100, precision_rounding=hour_rounding)
dashboard_values['rates']['total'] += float_round(data.get('unit_amount') / dashboard_total_hours * 100, precision_rounding=hour_rounding)
# money_amount
dashboard_values['money_amount']['invoiced'] = sum(values['timesheet_lines'].filtered(lambda l: l.timesheet_invoice_id).mapped('timesheet_revenue'))
dashboard_values['money_amount']['to_invoice'] = sum(values['timesheet_lines'].filtered(lambda l: not l.timesheet_invoice_id).mapped('timesheet_revenue'))
dashboard_values['money_amount']['cost'] = sum(values['timesheet_lines'].mapped('amount'))
dashboard_values['money_amount']['total'] = sum([dashboard_values['money_amount'][item] for item in dashboard_values['money_amount'].keys()])
values['dashboard'] = dashboard_values
# -- Time Repartition (per employee)
repartition_domain = domain + [('timesheet_invoice_type', '!=', False)] # force billable type
repartition_data = request.env['account.analytic.line'].read_group(repartition_domain, ['employee_id', 'timesheet_invoice_type', 'unit_amount'], ['employee_id', 'timesheet_invoice_type'], lazy=False)
# set repartition per type per employee
repartition_employee = {}
for 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=0.0,
billable_time=0.0,
billable_fixed=0.0,
total=0.0
))[data['timesheet_invoice_type']] = float_round(data.get('unit_amount', 0.0), precision_rounding=hour_rounding)
# 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])
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
values['repartition_employee'] = repartition_employee
return values
+1
View File
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import account
from . import account_invoice
from . import hr
from . import procurement
from . import product
+76 -8
View File
@@ -1,28 +1,43 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
from odoo.exceptions import UserError
from odoo import api, fields, models, _
class AccountAnalyticLine(models.Model):
_inherit = 'account.analytic.line'
timesheet_invoice_type = fields.Selection([
('billable_time', 'Billable Time'),
('billable_fixed', 'Billable Fixed'),
('non_billable', 'Non Billable')], string="Billable Type", readonly=True, copy=False)
timesheet_invoice_id = fields.Many2one('account.invoice', string="Invoice", readonly=True, copy=False, help="Invoice created from the timesheet")
timesheet_revenue = fields.Monetary("Revenue", default=0.0, readonly=True, currency_field='company_currency_id', copy=False)
@api.model
def create(self, values):
if values.get('task_id'):
task = self.env['project.task'].browse(values['task_id'])
values['so_line'] = task.sale_line_id.id or values.get('so_line', False)
values.update(self._get_timesheet_cost(values))
values.update(self._get_timesheet_values(values))
values.update(self._get_timesheet_billing_values(values))
return super(AccountAnalyticLine, self).create(values)
@api.multi
def write(self, values):
# prevent to update invoiced timesheets
if self.filtered(lambda timesheet: timesheet.timesheet_invoice_id):
if any([field_name in values for field_name in ['unit_amount', 'employee_id', 'task_id', 'timesheet_revenue', 'so_line', 'amount', 'date']]):
raise UserError(_('You can not modify already invoiced timesheets.'))
so_lines = self.mapped('so_line')
if values.get('task_id'):
task = self.env['project.task'].browse(values['task_id'])
values['so_line'] = task.sale_line_id.id or values.get('so_line', False)
for line in self:
values.update(line._get_timesheet_cost(values))
values.update(line._get_timesheet_values(values))
values.update(line._get_timesheet_billing_values(values))
super(AccountAnalyticLine, line).write(values)
# Update delivered quantity on SO lines which are not linked to the analytic lines anymore
@@ -31,7 +46,8 @@ class AccountAnalyticLine(models.Model):
so_lines.with_context(force_so_lines=so_lines).sudo()._compute_analytic()
return True
def _get_timesheet_cost(self, values):
def _get_timesheet_values(self, values):
result = {}
values = values if values is not None else {}
if values.get('project_id') or self.project_id:
if values.get('amount'):
@@ -55,12 +71,64 @@ class AccountAnalyticLine(models.Model):
amount = -unit_amount * cost
amount_converted = employee.currency_id.compute(amount, analytic_account.currency_id)
return {
result.update({
'amount': amount_converted,
'product_uom_id': uom.id,
'account_id': account_id,
}
return {}
})
return result
def _get_timesheet_billing_values(self, values):
"""
If invoice on delivered quantity:
timesheet hours * (SO Line Price) * (1- discount),
elif invoice on ordered quantities & create task:
min (
timesheet hours * (SO Line unit price) * (1- discount),
TOTAL SO - TOTAL INVOICED - sum(timesheet revenues with invoice_id=False)
)
else:
0
"""
result = {}
if self.env.context.get('create'): # avoid bad loop
return result
unit_amount = values.get('unit_amount', 0.0) or self.unit_amount
billable_type = 'non_billable'
revenue = 0.0
# set the revenue and billable type according to the product and the SO line
so_line_id = values.get('so_line') or self.so_line.id
so_line = self.env['sale.order.line'].browse(so_line_id) if so_line_id else self.env['sale.order.line'].browse()
if so_line.product_id.type == 'service':
# find the analytic account to convert revenue into its currency
account_id = values.get('account_id') or self.account_id.id
analytic_account = self.env['account.analytic.account'].browse(account_id)
# calculate the revenue on the timesheet
if so_line.product_id.invoice_policy == 'delivery':
delivered_revenue = unit_amount * so_line.price_unit * (1-so_line.discount)
revenue = so_line.currency_id.compute(delivered_revenue, analytic_account.currency_id) # amount from SO should be convert into analytic account currency
billable_type = 'billable_time'
elif so_line.product_id.invoice_policy == 'order' and so_line.product_id.track_service == 'task':
# compute the total revenue the SO since we are in fixed price
total_revenue_so = so_line.product_uom_qty * so_line.price_unit * (1-so_line.discount)
total_revenue_so = so_line.currency_id.compute(total_revenue_so, analytic_account.currency_id)
# compute the total revenue already existing (without the current timesheet line)
domain = [('so_line', '=', so_line.id)]
if self.ids:
domain += [('id', 'not in', self.ids)]
analytic_lines = self.sudo().search(domain)
total_revenue_invoiced = sum(analytic_lines.mapped('timesheet_revenue'))
# compute (new) revenue of current timesheet line
revenue = min(
so_line.currency_id.compute(unit_amount * (so_line.price_unit) * (1-so_line.discount), analytic_account.currency_id),
total_revenue_so - total_revenue_invoiced
)
billable_type = 'billable_fixed'
result['timesheet_revenue'] = revenue
result['timesheet_invoice_type'] = billable_type
return result
def _get_sale_order_line(self, vals=None):
result = dict(vals or {})
@@ -81,6 +149,6 @@ class AccountAnalyticLine(models.Model):
'so_line': sol.id,
'product_id': sol.product_id.id,
})
result.update(self._get_timesheet_cost(result))
result.update(self._get_timesheet_values(result))
return super(AccountAnalyticLine, self)._get_sale_order_line(vals=result)
@@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.tools.float_utils import float_round
class AccountInvoice(models.Model):
_inherit = "account.invoice"
timesheet_ids = fields.One2many('account.analytic.line', 'timesheet_invoice_id', string='Timesheets', readonly=True, copy=False)
timesheet_count = fields.Integer("Number of timesheets", compute='_compute_timesheet_count')
@api.multi
@api.depends('timesheet_ids')
def _compute_timesheet_count(self):
timesheet_data = self.env['account.analytic.line'].read_group([('timesheet_invoice_id', 'in', self.ids)], ['timesheet_invoice_id'], ['timesheet_invoice_id'])
mapped_data = dict([(t['timesheet_invoice_id'][0], t['timesheet_invoice_id_count']) for t in timesheet_data])
for invoice in self:
invoice.timesheet_count = mapped_data.get(invoice.id, 0)
def action_view_timesheet(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Timesheets'),
'domain': [('project_id', '!=', False)],
'res_model': 'account.analytic.line',
'view_id': False,
'view_mode': 'tree,form',
'view_type': 'form',
'help': _("""
<p class="oe_view_nocontent_create">
Click to record timesheets.
</p><p>
You can register and track your workings hours by project every
day. Every time spent on a project will become a cost and can be re-invoiced to
customers if required.
</p>
"""),
'limit': 80,
'context': {
'default_project_id': self.id,
'search_default_project_id': [self.id]
}
}
@api.multi
def invoice_validate(self):
result = super(AccountInvoice, self).invoice_validate()
for invoice in self:
for invoice_line in invoice.invoice_line_ids.filtered(lambda line: line.product_id.type == 'service'):
uninvoiced_timesheet_lines = self.env['account.analytic.line'].sudo().search([
('so_line', 'in', invoice_line.sale_line_ids.ids),
('project_id', '!=', False),
('timesheet_invoice_id', '=', False),
('timesheet_invoice_type', 'in', ['billable_time', 'billable_fixed'])
]).with_context(create=True) # context key required to avoid loop
# NOTE JEM : changing quantity (or unit price) of invoice line does not impact the revenue calculation. (FP specs)
if uninvoiced_timesheet_lines:
precision = invoice_line.currency_id.decimal_places
# delivered : update revenue with the prorata of number of hours on the timesheet line
if invoice_line.product_id.invoice_policy == 'delivery':
invoiced_price_per_hour = float_round(invoice_line.price_subtotal / float(sum(uninvoiced_timesheet_lines.mapped('unit_amount'))), precision)
# invoicing analytic lines of different currency
total_revenue_per_currency = dict.fromkeys(uninvoiced_timesheet_lines.mapped('company_currency_id').ids, 0.0)
for index, timesheet_line in enumerate(uninvoiced_timesheet_lines):
if index+1 != len(uninvoiced_timesheet_lines):
line_revenue = invoiced_price_per_hour * timesheet_line.unit_amount
total_revenue_per_currency[timesheet_line.company_currency_id.id] += line_revenue
else: # last line: add the difference to avoid rounding problem
total_revenue = sum([self.env['res.currency'].browse(currency_id).compute(amount, timesheet_line.company_currency_id) for currency_id, amount in total_revenue_per_currency.items()])
line_revenue = invoice_line.price_subtotal - total_revenue
timesheet_line.write({
'timesheet_invoice_id': invoice.id,
'timesheet_revenue': float_round(line_revenue, precision),
})
# ordered : update revenue with the prorata of theorical revenue
elif invoice_line.product_id.invoice_policy == 'order':
zero_timesheet_revenue = uninvoiced_timesheet_lines.filtered(lambda line: line.timesheet_revenue == 0.0)
no_zero_timesheet_revenue = uninvoiced_timesheet_lines.filtered(lambda line: line.timesheet_revenue != 0.0)
# timesheet with zero theorical revenue keep the same revenue, but become invoiced (invoice_id set)
zero_timesheet_revenue.write({'timesheet_invoice_id': invoice.id})
# invoicing analytic lines of different currency
total_revenue_per_currency = dict.fromkeys(no_zero_timesheet_revenue.mapped('company_currency_id').ids, 0.0)
for index, timesheet_line in enumerate(no_zero_timesheet_revenue):
if index+1 != len(no_zero_timesheet_revenue):
price_subtotal_inv = invoice_line.currency_id.compute(invoice_line.price_subtotal, timesheet_line.company_currency_id)
price_subtotal_sol = timesheet_line.so_line.currency_id.compute(timesheet_line.so_line.price_subtotal, timesheet_line.company_currency_id)
line_revenue = timesheet_line.timesheet_revenue * price_subtotal_inv / price_subtotal_sol
total_revenue_per_currency[timesheet_line.company_currency_id.id] += line_revenue
else: # last line: add the difference to avoid rounding problem
last_price_subtotal_inv = invoice_line.currency_id.compute(invoice_line.price_subtotal, timesheet_line.company_currency_id)
total_revenue = sum([self.env['res.currency'].browse(currency_id).compute(amount, timesheet_line.company_currency_id) for currency_id, amount in total_revenue_per_currency.items()])
line_revenue = last_price_subtotal_inv - total_revenue
timesheet_line.write({
'timesheet_invoice_id': invoice.id,
'timesheet_revenue': float_round(line_revenue, precision),
})
return result
+46
View File
@@ -5,6 +5,52 @@ from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class Project(models.Model):
_inherit = 'project.project'
@api.multi
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,
'domain': [('project_id', '!=', False)],
'res_model': 'account.analytic.line',
'view_id': False,
'view_mode': 'tree,form',
'view_type': 'form',
'help': _("""
<p class="oe_view_nocontent_create">
Click to record timesheets.
</p><p>
You can register and track your workings hours by project every
day. Every time spent on a project will become a cost and can be re-invoiced to
customers if required.
</p>
"""),
'limit': 80,
'context': {
'default_project_id': self.id,
'search_default_project_id': [self.id]
}
}
@api.multi
def action_view_timesheet_plan(self):
return {
'name': _('Overview of %s') % self.name,
'type': 'ir.actions.client',
'tag': 'timesheet.plan',
'context': {
'active_id': self.id,
'active_ids': self.ids,
'search_default_project_id': self.id,
}
}
class ProjectTask(models.Model):
_inherit = "project.task"
@@ -0,0 +1,203 @@
odoo.define('project_timesheet.project_plan', function (require) {
'use strict';
var ajax = require('web.ajax');
var ControlPanelMixin = require('web.ControlPanelMixin');
var Context = require('web.Context');
var core = require('web.core');
var data = require('web.data');
var pyeval = require('web.pyeval');
var SearchView = require('web.SearchView');
var session = require('web.session');
var Widget = require('web.Widget');
var QWeb = core.qweb;
var _t = core._t;
var PlanAction = Widget.extend(ControlPanelMixin, {
events: {
"click a[type='action']": "_onClickAction",
"click .o_timesheet_plan_redirect": '_onRedirect',
},
init: function(parent, action, options) {
this._super.apply(this, arguments);
this.action = action;
this.action_manager = parent;
},
willStart: function () {
var self = this;
var view_id = this.action && this.action.search_view_id && this.action.search_view_id[0];
var def = this
.loadViews('account.analytic.line', new Context(this.action.context || {}), [[view_id, 'search']])
.then(function (result) {
self.fields_view = result.search;
});
return $.when(this._super(), def);
},
start: function(){
var self = this;
// find default search from context
var search_defaults = {};
var context = this.action.context || [];
_.each(context, function (value, key) {
var match = /^search_default_(.*)$/.exec(key);
if (match) {
search_defaults[match[1]] = value;
}
});
// create searchview
var options = {
$buttons: $("<div>"),
action: this.action,
disable_groupby: true,
search_defaults: search_defaults,
};
var dataset = new data.DataSetSearch(this, 'account.analytic.line');
this.searchview = new SearchView(this, dataset, this.fields_view, options);
this.searchview.on('search', this, this._onSearch);
var def1 = this._super.apply(this, arguments);
var def2 = this.searchview.appendTo($("<div>")).then(function () {
self.$searchview_buttons = self.searchview.$buttons.contents();
});
return $.when(def1, def2).then(function(){
self.searchview.do_search();
self.update_cp();
});
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
do_show: function () {
this._super.apply(this, arguments);
this.update_cp();
this.action_manager.do_push_state({
action: this.action.id,
active_id: this.action.context.active_id,
});
},
update_cp: function () {
this.update_control_panel({
breadcrumbs: this.action_manager.get_breadcrumbs(),
cp_content: {
$buttons: this.$buttons,
$searchview: this.searchview.$el,
$searchview_buttons: this.$searchview_buttons,
},
searchview: this.searchview,
});
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Refresh the DOM html
* @param {string|html}
* @private
*/
_refreshPlan: function(dom){
this.$el.html(dom);
},
/**
* Call controller to get the html content
* @private
* @returns {Deferred}
*/
_fetchPlan: function(domain){
var self = this;
return this._rpc({
route:"/timesheet/plan",
params: {domain: domain},
}).then(function(result){
self._refreshPlan(result['html_content']);
});
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Generate the action to execute based on the clicked target
*
* @param {MouseEvent} event
* @private
*/
_onClickAction: function(ev){
var $target = this.$(ev.currentTarget);
var action = false;
if($target.attr('name')){ // classical case : name="action_id" type="action"
action = $target.attr('name')
}else{ // custom case : build custom action dict
action = {
'name': _t('Timesheet'),
'type': 'ir.actions.act_window',
'target': 'current',
'res_model': 'account.analytic.line',
}
// find action views
var views = [[false, 'pivot'], [false, 'list']];
if($target.attr('views')){
views = JSON.parse($target.attr('views').replace(/\'/g, '"'));
}
action['views'] = views;
action['view_mode'] = _.map(views, function(view_array){return view_array[1];});
// custom domain
var domain = [];
if($target.attr('domain')){
domain = JSON.parse($target.attr('domain').replace(/\'/g, '"'));
}
action['domain'] = domain;
}
// additionnal context
var context = {
active_id: this.action.context.active_id,
active_ids: this.action.context.active_ids,
active_model: this.action.context.active_model,
};
if($target.attr('context')){
var ctx_str = $target.attr('context').replace(/\'/g, '"');
context = _.extend(context, JSON.parse(ctx_str));
}
this.do_action(action, {
additional_context: context
});
},
_onRedirect: function (event) {
event.preventDefault();
var $target = $(event.target);
this.do_action({
type: 'ir.actions.act_window',
view_type: 'form',
view_mode: 'form',
res_model: $target.data('oe-model'),
views: [[false, 'form']],
res_id: $target.data('oe-id'),
});
},
_onSearch: function (search_event) {
var session = this.getSession();
// group by are disabled, so we don't take care of them
var result = pyeval.eval_domains_and_contexts({
domains: search_event.data.domains,
contexts: [session.user_context].concat(search_event.data.contexts)
});
this._fetchPlan(result.domain);
},
});
core.action_registry.add('timesheet.plan', PlanAction);
});
@@ -0,0 +1,61 @@
.o_timesheet_plan_sale_timesheet {
[type='action'] {
color: @odoo-brand-optional;
cursor: pointer;
}
.o_timesheet_plan_sale_timesheet_dashboard {
.table {
margin-top: 15px;
}
.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;
}
}
.o_timesheet_plan_sale_timesheet_people_time {
.o_progress_billable_time {
background-color: #f0ad4e;
color: white;
}
.o_progress_billable_fixed {
background-color: #5bc0de;
color: white;
}
.o_progress_non_billable {
background-color: gray;
color: white;
}
>.o_timesheet_plan_badge {
margin-bottom: 10px;
> .badge {
border: none;
}
}
.table {
margin-top:15px;
}
.table > thead > tr > th, .table > thead > tr > td,
.table > tbody > tr > th, .table > tbody > tr > td {
border: none;
padding: 0px;
}
}
}
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_timesheet_from_invoice" model="ir.actions.act_window">
<field name="name">Timesheet</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">account.analytic.line</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form,graph</field>
<field name="context">{'search_default_timesheet_invoice_id': active_id}</field>
</record>
<record id="account_invoice_view_form_inherit_sale_timesheet" model="ir.ui.view">
<field name="name">account.invoice.form.inherit.timesheet</field>
<field name="model">account.invoice</field>
<field name="inherit_id" ref="account.invoice_form"/>
<field name="arch" type="xml">
<xpath expr="//label" position="before">
<div class="oe_button_box" name="button_box" groups="base.group_user">
<button name="%(sale_timesheet.action_timesheet_from_invoice)d" type="action" class="oe_stat_button" icon="fa-calendar">
<field name="timesheet_count" widget="statinfo" string="Timesheets"/>
</button>
</div>
</xpath>
</field>
</record>
</odoo>
@@ -0,0 +1,143 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="assets_backend" name="sale timesheet assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<script type="text/javascript" src="/sale_timesheet/static/src/js/timesheet_plan.js"></script>
<link rel="stylesheet" href="/sale_timesheet/static/src/less/sale_timesheet.less"/>
</xpath>
</template>
<template id="timesheet_plan" name="Timesheet Plan">
<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">
<h3>Overview</h3>
<table class="table">
<tbody>
<tr>
<th rowspan="4">
<a type="action" t-att-domain="json.dumps(domain)" context="{'pivot_row_group_by': ['date:month'],'pivot_column_group_by': ['timesheet_invoice_type'], 'measures': ['unit_amount']}">Hours</a>
</th>
<td class="o_timesheet_plan_dashboard_cell">
<t t-esc="dashboard['hours']['billable_time']"/> h
</td>
<td>Time and material</td>
<th rowspan="4">
<a type="action" t-att-domain="json.dumps(domain)" context="{'pivot_row_group_by': ['date:month', 'employee_id'], 'pivot_column_group_by': ['timesheet_invoice_type'],'pivot_measures': ['unit_amount']}">Rates</a>
</th>
<td class="o_timesheet_plan_dashboard_cell">
<t t-esc="dashboard['rates']['billable_time']"/> %
</td>
<td>Time and material</td>
<th rowspan="4">
<a type="action" t-att-domain="json.dumps(domain)" context="{'pivot_row_group_by': ['timesheet_invoice_id'], 'pivot_column_group_by': ['date:month'],'pivot_measures': ['amount', 'timesheet_revenue']}">Profitability</a>
</th>
<td class="o_timesheet_plan_dashboard_cell">
<t t-if="currency.position == 'before'" t-esc="currency.symbol"/>
<t t-esc="dashboard['money_amount']['invoiced']"/>
<t t-if="currency.position == 'after'" t-esc="currency.symbol"/>
</td>
<td>Invoiced</td>
</tr>
<tr>
<td class="o_timesheet_plan_dashboard_cell">
<t t-esc="dashboard['hours']['billable_fixed']"/> h
</td>
<td>Fixed</td>
<td class="o_timesheet_plan_dashboard_cell">
<t t-esc="dashboard['rates']['billable_fixed']"/> %
</td>
<td>Fixed</td>
<td class="o_timesheet_plan_dashboard_cell">
<t t-if="currency.position == 'before'" t-esc="currency.symbol"/>
<t t-esc="dashboard['money_amount']['to_invoice']"/>
<t t-if="currency.position == 'after'" t-esc="currency.symbol"/>
</td>
<td>To invoice</td>
</tr>
<tr>
<td class="o_timesheet_plan_dashboard_cell">
<t t-esc="dashboard['hours']['non_billable']"/> h
</td>
<td>Non Billable</td>
<td class="o_timesheet_plan_dashboard_cell">
<t t-esc="dashboard['rates']['non_billable']"/> %
</td>
<td>Non Billable</td>
<td class="o_timesheet_plan_dashboard_cell">
<t t-if="currency.position == 'before'" t-esc="currency.symbol"/>
<t t-esc="dashboard['money_amount']['cost']"/>
<t t-if="currency.position == 'after'" t-esc="currency.symbol"/>
</td>
<td>Cost</td>
</tr>
<tr>
<td class="o_timesheet_plan_dashboard_total">
<t t-esc="dashboard['hours']['total']"/> h
</td>
<td><b>Total</b></td>
<td class="o_timesheet_plan_dashboard_total">
<t t-esc="dashboard['rates']['total']"/> %
</td>
<td><b>Total</b></td>
<td class="o_timesheet_plan_dashboard_total">
<t t-if="currency.position == 'before'" t-esc="currency.symbol"/>
<t t-esc="dashboard['money_amount']['total']"/>
<t t-if="currency.position == 'after'" t-esc="currency.symbol"/>
</td>
<td><b>Total</b></td>
</tr>
</tbody>
</table>
</div>
<div class="o_timesheet_plan_sale_timesheet_people_time">
<h3>Time by people</h3>
<t t-if="not repartition_employee">
<p>There is no timesheet for now.</p>
</t>
<t t-if="repartition_employee">
<div class="pull-right o_timesheet_plan_badge">
<span class="badge o_progress_billable_fixed">Billable fixed</span>
<span class="badge o_progress_billable_time">Billable time</span>
<span class="badge o_progress_non_billable">Non billable</span>
</div>
<table class="table ">
<tbody>
<t t-foreach="repartition_employee" t-as="employee_id">
<tr>
<td style="width: 15%">
<a type="action" t-att-domain="json.dumps(domain)" t-att-context="{'search_default_employee_id': employee_id}" views="[[0, 'list']]">
<t t-esc="repartition_employee[employee_id]['employee_name']"/>
</a>
</td>
<td style="width: 10%">
<t t-esc="repartition_employee[employee_id]['total']"/> hours
</td>
<td>
<div class="progress" t-att-style="'width: ' + str(repartition_employee[employee_id]['total'] / repartition_employee_max * 100) +'%'">
<div t-if="repartition_employee[employee_id]['billable_fixed']" class="progress-bar o_progress_billable_fixed" t-att-style="'width: ' + str(repartition_employee[employee_id]['billable_fixed'] / repartition_employee[employee_id]['total'] * 100) + '%'" t-att-title="str(repartition_employee[employee_id]['billable_fixed']) + ' hours'">
</div>
<div t-if="repartition_employee[employee_id]['billable_time']" class="progress-bar o_progress_billable_time" t-att-style="'width: ' + str(repartition_employee[employee_id]['billable_time'] / repartition_employee[employee_id]['total'] * 100) + '%'" t-att-title="str(repartition_employee[employee_id]['billable_time']) + ' hours'">
</div>
<div t-if="repartition_employee[employee_id]['non_billable']" class="progress-bar o_progress_non_billable" t-att-style="'width: ' + str(repartition_employee[employee_id]['non_billable'] / repartition_employee[employee_id]['total'] * 100) + '%'" t-att-title="str(repartition_employee[employee_id]['non_billable']) + ' hours'">
</div>
</div>
</td>
</tr>
</t>
</tbody>
</table>
</t>
</div>
</div>
</div>
</div>
</div>
</template>
</odoo>
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="timesheet_view_search" model="ir.ui.view">
<field name="name">account.analytic.line.search</field>
<field name="model">account.analytic.line</field>
<field name="inherit_id" ref="hr_timesheet.hr_timesheet_line_search"/>
<field name="arch" type="xml">
<xpath expr="//filter[@name='month']" position="after">
<field name="timesheet_invoice_type"/>
<field name="timesheet_invoice_id"/>
<separator/>
<filter name="billable_time" string="Billable Hours" domain="[('timesheet_invoice_type', '=', 'billable_time')]"/>
<filter name="billable_fixed" string="Fixed Price Projects" domain="[('timesheet_invoice_type', '=', 'billable_fixed')]"/>
<filter name="non_billable" string="Non Billable Hours" domain="[('timesheet_invoice_type', '=', 'non_billable')]"/>
</xpath>
</field>
</record>
<!--
Reporting
-->
<record id="timesheet_action_billing_report" model="ir.actions.act_window">
<field name="name">Billing Rate Analysis</field>
<field name="res_model">account.analytic.line</field>
<field name="view_mode">pivot,graph</field>
<field name="domain">[('project_id', '!=', False)]</field>
<field name="context">{"search_default_week":1}</field>
<field name="search_view_id" ref="hr_timesheet.hr_timesheet_line_search"/>
</record>
<record id="timesheet_filter_billing" model="ir.filters">
<field name="name">Billing Rate</field>
<field name="model_id">account.analytic.line</field>
<field name="user_id" eval="False"/>
<field name="domain">[('project_id', '!=', False)]</field>
<field name="is_default" eval="True"/>
<field name="context">{
'col_group_by': ['date:month', 'timesheet_invoice_type'],
'group_by': ['project_id', 'employee_id'],
'measures': ['timesheet_revenue', 'amount_currency', 'unit_amount', '__count']
}</field>
<field name="action_id" ref="timesheet_action_billing_report"/>
</record>
<menuitem id="menu_timesheet_billing_analysis"
parent="hr_timesheet.menu_timesheets_reports"
action="timesheet_action_billing_report"
name="Billing Rate Analysis"/>
<!--
Plan
-->
<record id="timesheet_action_plan_pivot" model="ir.actions.act_window">
<field name="name">Timesheet</field>
<field name="res_model">account.analytic.line</field>
<field name="view_mode">pivot,tree,form</field>
<field name="domain">[('project_id', '!=', False)]</field>
<field name="search_view_id" ref="hr_timesheet.hr_timesheet_line_search"/>
</record>
<record id="timesheet_action_from_plan" model="ir.actions.act_window">
<field name="name">Timesheet</field>
<field name="res_model">account.analytic.line</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('project_id', '!=', False)]</field>
<field name="search_view_id" ref="hr_timesheet.hr_timesheet_line_search"/>
</record>
</odoo>
@@ -16,4 +16,34 @@
</field>
</field>
</record>
<record id="product_template_search_view_sale_timesheet" model="ir.ui.view">
<field name="name">product.template.search.timesheet</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_search_view"/>
<field name="arch" type="xml">
<xpath expr="//filter[@name='consumable']" position="after">
<separator/>
<filter string="Time-based services" name="product_time_based" domain="[('type', '=', 'service'), ('invoice_policy', '=', 'delivery')]"/>
<filter string="Fixed price services" name="product_service_fixed" domain="[('type', '=', 'service'), ('invoice_policy', '=', 'order')]"/>
</xpath>
</field>
</record>
<record id="product_template_action_time_based" model="ir.actions.act_window">
<field name="name">Products</field>
<field name="res_model">product.template</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_product_time_based':1, 'default_type': 'service', 'default_invoice_policy': 'delivery'}</field>
</record>
<record id="product_template_action_fixed" model="ir.actions.act_window">
<field name="name">Products</field>
<field name="res_model">product.template</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_product_service_fixed':1, 'default_type': 'service', 'default_invoice_policy': 'order'}</field>
</record>
</odoo>
@@ -1,5 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="project_project_view_kanban_inherit_sale_timesheet" model="ir.ui.view">
<field name="name">project.project.kanban.inherit.sale.timesheet</field>
<field name="model">project.project</field>
<field name="inherit_id" ref="hr_timesheet.view_project_kanban_inherited"/>
<field name="arch" type="xml">
<xpath expr="//a[@t-if='record.allow_timesheets.raw_value']" position="replace">
<a t-if="record.allow_timesheets.raw_value" name="action_view_timesheet" type="object">
<span class="o_label">Overview</span>
</a>
</xpath>
</field>
</record>
<record id="view_sale_service_inherit_form2" model="ir.ui.view">
<field name="name">sale.service.form.view.inherit</field>
<field name="model">project.task</field>
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="hr_timesheet_config_settings_view_form" model="ir.ui.view">
<field name="name">hr.timesheet.config.settings.view.form</field>
<field name="model">hr.timesheet.config.settings</field>
<field name="inherit_id" ref="hr_timesheet.hr_timesheet_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='msg_module_sale_timesheet']" position="replace">
<div class="content-group" attrs="{'invisible': [('module_sale_timesheet', '=', False)]}">
<div class="mt16">
<div>
<button name="%(sale_timesheet.product_template_action_time_based)d" string="Setup your time-based services" type="action" class="btn-link" icon="fa-arrow-right"/>
</div>
<div>
<button name="%(sale_timesheet.product_template_action_fixed)d" string="Setup your fixed price services" type="action" class="btn-link" icon="fa-arrow-right"/>
</div>
</div>
</div>
</xpath>
</field>
</record>
<record id="sale_config_settings_view_form_inherit_sale_timesheet" model="ir.ui.view">
<field name="name">sale.config.settings.view.form</field>
<field name="model">sale.config.settings</field>
<field name="inherit_id" ref="sale.sale_config_settings_view_form_inherit_sale"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='msg_module_sale_timesheet']" position="replace">
<div class="content-group" attrs="{'invisible': [('module_sale_timesheet', '=', False)]}">
<div class="mt16">
<div>
<button name="%(sale_timesheet.product_template_action_time_based)d" string="Setup your time-based services" type="action" class="btn-link" icon="fa-arrow-right"/>
</div>
<div>
<button name="%(sale_timesheet.product_template_action_fixed)d" string="Setup your fixed price services" type="action" class="btn-link" icon="fa-arrow-right"/>
</div>
</div>
</div>
</xpath>
</field>
</record>
<record id="project_config_settings_view_form" model="ir.ui.view">
<field name="name">project.config.settings.view.form</field>
<field name="model">project.config.settings</field>
<field name="inherit_id" ref="project.project_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='msg_module_sale_timesheet']" position="replace">
<div class="content-group" attrs="{'invisible': [('module_sale_timesheet', '=', False)]}">
<div class="mt16">
<div>
<button name="%(sale_timesheet.product_template_action_time_based)d" string="Setup your time-based services" type="action" class="btn-link" icon="fa-arrow-right"/>
</div>
<div>
<button name="%(sale_timesheet.product_template_action_fixed)d" string="Setup your fixed price services" type="action" class="btn-link" icon="fa-arrow-right"/>
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>