From b6d697a1e9cdc9cc76b72541e5b42ae39dc6240e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Maes?= Date: Wed, 3 May 2017 14:27:51 +0200 Subject: [PATCH 1/5] [IMP] project_timesheet_holidays: hide internal project/task on leave type form Since it is advanced feature, you should be in debug mode to configure them. --- addons/project_timesheet_holidays/views/hr_holidays_views.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addons/project_timesheet_holidays/views/hr_holidays_views.xml b/addons/project_timesheet_holidays/views/hr_holidays_views.xml index ca1ae705bda..27af36b85f8 100644 --- a/addons/project_timesheet_holidays/views/hr_holidays_views.xml +++ b/addons/project_timesheet_holidays/views/hr_holidays_views.xml @@ -9,8 +9,8 @@ - - + + From 12866bd6145d45b0bf726a7387327deed02613c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Maes?= Date: Thu, 4 May 2017 15:13:57 +0200 Subject: [PATCH 2/5] [IMP] hr_timesheet: privacy of some field on employee form view --- addons/hr_timesheet/views/hr_views.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/hr_timesheet/views/hr_views.xml b/addons/hr_timesheet/views/hr_views.xml index 82403ffd01e..d92c9680c66 100644 --- a/addons/hr_timesheet/views/hr_views.xml +++ b/addons/hr_timesheet/views/hr_views.xml @@ -20,7 +20,7 @@ - + From 227f8ab116186f93967840274086b78ac67697f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Maes?= Date: Tue, 21 Mar 2017 09:55:04 +0100 Subject: [PATCH 3/5] [IMP] sale_timesheet: introduce timesheet revenue This commit introduces the concept of timesheet revenue, aka how much the timesheet bring back money. If the timesheet is not invoiced yet, the computed revenue is theorical, based on the billable type (if your product is Time and Material, or Fixed Price). The formulae use at the timesheet line creation is If product on delivered quantity: timesheet hours * (SO Line Price) * (1- discount), elif product 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: (non billable case) 0 When the timesheet is invoiced, the revenue is recomputed based on the prorata of the invoice amount : prorata of number of hours for 'time and material' product, prorata of the theorical revenue for 'fixed price' product. The timesheet revenue is expressed in the currency of the company (Monetary field, based on the company_currency_id field of account.analytic.line) to ease reporting. This feature normally can handle multi currency (different currency from SO, INV, and analytic account). --- addons/sale/models/sale.py | 14 +-- addons/sale_timesheet/__manifest__.py | 2 + addons/sale_timesheet/models/__init__.py | 1 + addons/sale_timesheet/models/account.py | 84 ++++++++++++-- .../sale_timesheet/models/account_invoice.py | 108 ++++++++++++++++++ .../views/account_invoice_views.xml | 28 +++++ .../views/hr_timesheet_views.xml | 51 +++++++++ 7 files changed, 273 insertions(+), 15 deletions(-) create mode 100644 addons/sale_timesheet/models/account_invoice.py create mode 100644 addons/sale_timesheet/views/account_invoice_views.xml create mode 100644 addons/sale_timesheet/views/hr_timesheet_views.xml diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 81ddeea08d8..06cdd3abec2 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -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): diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py index 34cb04b6546..317327e433d 100644 --- a/addons/sale_timesheet/__manifest__.py +++ b/addons/sale_timesheet/__manifest__.py @@ -17,11 +17,13 @@ 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', ], 'demo': [ 'data/sale_service_demo.xml', diff --git a/addons/sale_timesheet/models/__init__.py b/addons/sale_timesheet/models/__init__.py index de19de2fbaf..17cb3f027f9 100644 --- a/addons/sale_timesheet/models/__init__.py +++ b/addons/sale_timesheet/models/__init__.py @@ -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 diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py index 7f319ead699..f544091a1f9 100644 --- a/addons/sale_timesheet/models/account.py +++ b/addons/sale_timesheet/models/account.py @@ -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) diff --git a/addons/sale_timesheet/models/account_invoice.py b/addons/sale_timesheet/models/account_invoice.py new file mode 100644 index 00000000000..2aabf31ac63 --- /dev/null +++ b/addons/sale_timesheet/models/account_invoice.py @@ -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': _(""" +

+ Click to record timesheets. +

+ 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. +

+ """), + '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 diff --git a/addons/sale_timesheet/views/account_invoice_views.xml b/addons/sale_timesheet/views/account_invoice_views.xml new file mode 100644 index 00000000000..ab9ee8388f1 --- /dev/null +++ b/addons/sale_timesheet/views/account_invoice_views.xml @@ -0,0 +1,28 @@ + + + + + Timesheet + ir.actions.act_window + account.analytic.line + form + tree,form,graph + {'search_default_timesheet_invoice_id': active_id} + + + + account.invoice.form.inherit.timesheet + account.invoice + + + +
+ +
+
+
+
+ +
diff --git a/addons/sale_timesheet/views/hr_timesheet_views.xml b/addons/sale_timesheet/views/hr_timesheet_views.xml new file mode 100644 index 00000000000..ec8e91f5c19 --- /dev/null +++ b/addons/sale_timesheet/views/hr_timesheet_views.xml @@ -0,0 +1,51 @@ + + + + + account.analytic.line.search + account.analytic.line + + + + + + + + + + + + + + + + Billing Rate Analysis + account.analytic.line + pivot,graph + [('project_id', '!=', False)] + {"search_default_week":1} + + + + + Billing Rate + account.analytic.line + + [('project_id', '!=', False)] + + { + 'col_group_by': ['date:month', 'timesheet_invoice_type'], + 'group_by': ['project_id', 'employee_id'], + 'measures': ['timesheet_revenue', 'amount_currency', 'unit_amount', '__count'] + } + + + + + + From 7c3f774d1cf7c275736fe710d506bd89bc91c2f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Maes?= Date: Mon, 10 Apr 2017 15:51:43 +0200 Subject: [PATCH 4/5] [IMP] sale,project,sale_timesheet: config settings The purpose is to ease timesheet configuration by adding missing settings: 'invoicing policy' setting exists in sale config. This commit duplicate it into timesheet config. It also add settings in project, sale and timesheet to configure more easily the service products (time and material, and fixed price), by adding filter for services. --- addons/project/views/res_config_views.xml | 5 ++ addons/sale_timesheet/__manifest__.py | 1 + addons/sale_timesheet/views/product_views.xml | 30 +++++++++ .../sale_timesheet/views/res_config_views.xml | 64 +++++++++++++++++++ 4 files changed, 100 insertions(+) create mode 100644 addons/sale_timesheet/views/res_config_views.xml diff --git a/addons/project/views/res_config_views.xml b/addons/project/views/res_config_views.xml index de26444ad29..c56bf1bf247 100644 --- a/addons/project/views/res_config_views.xml +++ b/addons/project/views/res_config_views.xml @@ -113,6 +113,11 @@
Sell services and invoice time spent
+
+
+ Save this page and come back here to set up the feature. +
+
diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py index 317327e433d..b06dc938520 100644 --- a/addons/sale_timesheet/__manifest__.py +++ b/addons/sale_timesheet/__manifest__.py @@ -24,6 +24,7 @@ have real delivered quantities in sales orders. 'views/project_task_views.xml', 'views/procurement_views.xml', 'views/hr_timesheet_views.xml', + 'views/res_config_views.xml', ], 'demo': [ 'data/sale_service_demo.xml', diff --git a/addons/sale_timesheet/views/product_views.xml b/addons/sale_timesheet/views/product_views.xml index 9e054c83f92..0c9903233a3 100644 --- a/addons/sale_timesheet/views/product_views.xml +++ b/addons/sale_timesheet/views/product_views.xml @@ -16,4 +16,34 @@ + + + product.template.search.timesheet + product.template + + + + + + + + + + + + Products + product.template + form + tree,form + {'search_default_product_time_based':1, 'default_type': 'service', 'default_invoice_policy': 'delivery'} + + + + Products + product.template + form + tree,form + {'search_default_product_service_fixed':1, 'default_type': 'service', 'default_invoice_policy': 'order'} + + diff --git a/addons/sale_timesheet/views/res_config_views.xml b/addons/sale_timesheet/views/res_config_views.xml new file mode 100644 index 00000000000..1be92384b48 --- /dev/null +++ b/addons/sale_timesheet/views/res_config_views.xml @@ -0,0 +1,64 @@ + + + + + hr.timesheet.config.settings.view.form + hr.timesheet.config.settings + + + +
+
+
+
+
+
+
+
+
+
+
+ + + sale.config.settings.view.form + sale.config.settings + + + +
+
+
+
+
+
+
+
+
+
+
+ + + project.config.settings.view.form + project.config.settings + + + +
+
+
+
+
+
+
+
+
+
+
+ +
From 11b14ff77c259230f16e4394313752ed07bad9f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Maes?= Date: Thu, 4 May 2017 10:58:47 +0200 Subject: [PATCH 5/5] [IMP] sale_timesheet: add timesheet overview This commits add the timesheet overview on the project kanban. Instead of redirecting to timesheet list, the user will see a more detailled view. The timesheet dashboard shows the amount per billable types, the numbers of hours provided per employee and per billable types, ... This view is integrated with the search view : changing the filter will shows you the informations for your selection. The purpose is to give the user a global view of the project (or other timesheet) financial situation in the same place. This overview is made to be extended in enterprise to add a section to compare past (timesheets) and futures (forecasts) activities. --- addons/sale_timesheet/__init__.py | 1 + addons/sale_timesheet/__manifest__.py | 1 + addons/sale_timesheet/controllers/__init__.py | 4 + addons/sale_timesheet/controllers/main.py | 86 ++++++++ addons/sale_timesheet/models/project.py | 46 ++++ .../static/src/js/timesheet_plan.js | 203 ++++++++++++++++++ .../static/src/less/sale_timesheet.less | 61 ++++++ .../views/hr_timesheet_templates.xml | 143 ++++++++++++ .../views/hr_timesheet_views.xml | 19 ++ .../views/project_task_views.xml | 14 ++ 10 files changed, 578 insertions(+) create mode 100644 addons/sale_timesheet/controllers/__init__.py create mode 100644 addons/sale_timesheet/controllers/main.py create mode 100644 addons/sale_timesheet/static/src/js/timesheet_plan.js create mode 100644 addons/sale_timesheet/static/src/less/sale_timesheet.less create mode 100644 addons/sale_timesheet/views/hr_timesheet_templates.xml diff --git a/addons/sale_timesheet/__init__.py b/addons/sale_timesheet/__init__.py index dc5e6b693d1..8c67f893b37 100644 --- a/addons/sale_timesheet/__init__.py +++ b/addons/sale_timesheet/__init__.py @@ -2,3 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import models +from . import controllers diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py index b06dc938520..89e5f4eb9cb 100644 --- a/addons/sale_timesheet/__manifest__.py +++ b/addons/sale_timesheet/__manifest__.py @@ -25,6 +25,7 @@ have real delivered quantities in sales orders. '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', diff --git a/addons/sale_timesheet/controllers/__init__.py b/addons/sale_timesheet/controllers/__init__.py new file mode 100644 index 00000000000..5d4b25db9c0 --- /dev/null +++ b/addons/sale_timesheet/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main diff --git a/addons/sale_timesheet/controllers/main.py b/addons/sale_timesheet/controllers/main.py new file mode 100644 index 00000000000..7aff42ff99d --- /dev/null +++ b/addons/sale_timesheet/controllers/main.py @@ -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 diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 40567c5fc5f..d8a15aec580 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -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': _(""" +

+ Click to record timesheets. +

+ 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. +

+ """), + '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" diff --git a/addons/sale_timesheet/static/src/js/timesheet_plan.js b/addons/sale_timesheet/static/src/js/timesheet_plan.js new file mode 100644 index 00000000000..0ee353240c2 --- /dev/null +++ b/addons/sale_timesheet/static/src/js/timesheet_plan.js @@ -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: $("
"), + 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($("
")).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); + +}); diff --git a/addons/sale_timesheet/static/src/less/sale_timesheet.less b/addons/sale_timesheet/static/src/less/sale_timesheet.less new file mode 100644 index 00000000000..b420a53a2a2 --- /dev/null +++ b/addons/sale_timesheet/static/src/less/sale_timesheet.less @@ -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; + } + } + + +} diff --git a/addons/sale_timesheet/views/hr_timesheet_templates.xml b/addons/sale_timesheet/views/hr_timesheet_templates.xml new file mode 100644 index 00000000000..22f82e1ed28 --- /dev/null +++ b/addons/sale_timesheet/views/hr_timesheet_templates.xml @@ -0,0 +1,143 @@ + + + + + + + + diff --git a/addons/sale_timesheet/views/hr_timesheet_views.xml b/addons/sale_timesheet/views/hr_timesheet_views.xml index ec8e91f5c19..70f3be7ac24 100644 --- a/addons/sale_timesheet/views/hr_timesheet_views.xml +++ b/addons/sale_timesheet/views/hr_timesheet_views.xml @@ -48,4 +48,23 @@ action="timesheet_action_billing_report" name="Billing Rate Analysis"/> + + + Timesheet + account.analytic.line + pivot,tree,form + [('project_id', '!=', False)] + + + + + Timesheet + account.analytic.line + tree,form + [('project_id', '!=', False)] + + + diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index c68e03991a4..b8b02461c44 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -1,5 +1,19 @@ + + + project.project.kanban.inherit.sale.timesheet + project.project + + + + + Overview + + + + + sale.service.form.view.inherit project.task