From 5cf4c350cb4647be294b21ba39dc4637225ad131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Maes?= Date: Thu, 22 Jun 2017 11:38:00 +0200 Subject: [PATCH 01/20] [REF] sale,sale_timesheet: cleaning sale analytic The handling of analytic lines to sell is a little mess. We can reinvoice analytic lines from timesheet or expenses, or manual lines. This commit tries to clean the code, factorize it, make proper hook (see _sale_postprocess method) and move specific in its corresponding modules. Code was properly split, in order to make modules responsible for only their own features: - sale module handle the link of SO line to an analytic line, and increment the delivered qty accordingly. sale also provide the mecanism of determine magically the SO lines of an analytic line with an expensable product to reinvoice it at cost. - sale_timesheet identifies the so line concerned when timesheeting on a project directly. Also provide the domain of analytic line when setting the SO line on AAL. Cleaning is necessary for futher dev. --- addons/sale/models/__init__.py | 2 +- addons/sale/models/analytic.py | 132 ++++++++++++++++++ addons/sale/models/sale.py | 48 +++++++ addons/sale/models/sale_analytic.py | 147 --------------------- addons/sale_timesheet/models/account.py | 55 ++++---- addons/sale_timesheet/models/sale_order.py | 18 ++- 6 files changed, 219 insertions(+), 183 deletions(-) create mode 100644 addons/sale/models/analytic.py delete mode 100644 addons/sale/models/sale_analytic.py diff --git a/addons/sale/models/__init__.py b/addons/sale/models/__init__.py index e5c5e1ecb51..5f6e7ba2857 100644 --- a/addons/sale/models/__init__.py +++ b/addons/sale/models/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import analytic from . import account_invoice from . import product_pricelist from . import product_product @@ -8,7 +9,6 @@ from . import product_template from . import res_company from . import res_partner from . import sale -from . import sale_analytic from . import res_config_settings from . import sale_layout from . import sales_team diff --git a/addons/sale/models/analytic.py b/addons/sale/models/analytic.py new file mode 100644 index 00000000000..fc0c25a3a11 --- /dev/null +++ b/addons/sale/models/analytic.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + + +class AccountAnalyticLine(models.Model): + _inherit = "account.analytic.line" + + so_line = fields.Many2one('sale.order.line', string='Sales Order Line') + + @api.model + def create(self, values): + result = super(AccountAnalyticLine, self).create(values) + result._sale_postprocess(values) + return result + + @api.multi + def write(self, values): + # get current so lines for which update qty wil be required + sale_order_lines = self.env['sale.order.line'] + if 'so_line' in values: + sale_order_lines = self.sudo().mapped('so_line') + result = super(AccountAnalyticLine, self).write(values) + self._sale_postprocess(values, additional_so_lines=sale_order_lines) + return result + + @api.multi + def unlink(self): + sale_order_lines = self.sudo().mapped('so_line') + res = super(AccountAnalyticLine, self).unlink() + sale_order_lines._analytic_compute_delivered_quantity() + return res + + @api.model + def _sale_get_fields_delivered_qty(self): + """ Returns a list with the field impacting the delivered quantity on SO line. """ + return ['so_line', 'unit_amount', 'product_uom_id'] + + @api.multi + def _sale_postprocess(self, values, additional_so_lines=None): + if 'so_line' not in values: # allow to force a False value for so_line + self.with_context(sale_analytic_norecompute=True)._sale_determine_order_line() + + if any(field_name in values for field_name in self._sale_get_fields_delivered_qty()): + if not self._context.get('sale_analytic_norecompute'): + so_lines = self.sudo().filtered(lambda aal: aal.so_line).mapped('so_line') + if additional_so_lines: + so_lines |= additional_so_lines + so_lines.sudo()._analytic_compute_delivered_quantity() + + # NOTE JEM: thoses method are used in vendor bills to reinvoice at cost (see test `test_cost_invoicing`) + # some cleaning are still necessary + + @api.multi + def _sale_get_invoice_price(self, order): + self.ensure_one() + if self.product_id.expense_policy == 'sales_price': + return self.product_id.with_context( + partner=order.partner_id.id, + date_order=order.date_order, + pricelist=order.pricelist_id.id, + uom=self.product_uom_id.id + ).price + if self.unit_amount == 0.0: + return 0.0 + + # Prevent unnecessary currency conversion that could be impacted by exchange rate + # fluctuations + if self.currency_id and self.amount_currency and self.currency_id == order.currency_id: + return abs(self.amount_currency / self.unit_amount) + + price_unit = abs(self.amount / self.unit_amount) + currency_id = self.company_id.currency_id + if currency_id and currency_id != order.currency_id: + price_unit = currency_id.compute(price_unit, order.currency_id) + return price_unit + + @api.multi + def _sale_prepare_sale_order_line_values(self, order, price): + self.ensure_one() + last_so_line = self.env['sale.order.line'].search([('order_id', '=', order.id)], order='sequence desc', limit=1) + last_sequence = last_so_line.sequence + 1 if last_so_line else 100 + + fpos = order.fiscal_position_id or order.partner_id.property_account_position_id + taxes = fpos.map_tax(self.product_id.taxes_id, self.product_id, order.partner_id) + + return { + 'order_id': order.id, + 'name': self.name, + 'sequence': last_sequence, + 'price_unit': price, + 'tax_id': [x.id for x in taxes], + 'discount': 0.0, + 'product_id': self.product_id.id, + 'product_uom': self.product_uom_id.id, + 'product_uom_qty': 0.0, + 'qty_delivered': self.unit_amount, + } + + @api.multi + def _sale_determine_order_line(self): + """ Automatically set the SO line on the analytic line, for the expense/vendor bills flow. It retrives + an existing line, or create a new one (upselling expenses). + """ + for analytic_line in self.sudo().filtered(lambda aal: not aal.so_line and aal.account_id and aal.product_id and aal.product_id.expense_policy != 'no'): + # determine SO : first SO open linked to AA + sale_order = self.env['sale.order'].search([('project_id', '=', analytic_line.account_id.id), ('state', '=', 'sale')], limit=1) + if not sale_order: + sale_order = self.env['sale.order'].search([('project_id', '=', analytic_line.account_id.id)], limit=1) + if not sale_order: + continue + + price = analytic_line._sale_get_invoice_price(sale_order) + so_line = self.env['sale.order.line'].search([ + ('order_id', '=', sale_order.id), + ('price_unit', '=', price), + ('product_id', '=', self.product_id.id) + ], limit=1) + + if not so_line: + # generate a new SO line + if sale_order.state != 'sale': + raise UserError(_('The Sales Order %s linked to the Analytic Account must be validated before registering expenses.') % sale_order.name) + so_line_values = analytic_line._sale_prepare_sale_order_line_values(sale_order, price) + if so_line_values: + so_line = self.env['sale.order.line'].create(so_line_values) + so_line._compute_tax_id() + + if so_line: # if so line found or created, then update AAL (this will trigger the recomputation of qty delivered on SO line) + analytic_line.write({'so_line': so_line.id}) diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 4d6ba4d193b..b8b7d4890f8 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -1087,3 +1087,51 @@ class SaleOrderLine(models.Model): discount = (new_list_price - price) / new_list_price * 100 if discount > 0: self.discount = discount + + ########################### + # Analytic Methods + ########################### + + @api.multi + def _analytic_compute_delivered_quantity_domain(self): + """ Return the domain of the analytic lines to use to recompute the delivered quantity + on SO lines. This method is a hook: since analytic line are used for timesheet, + expense, ... each use case should provide its part of the domain. + """ + return [('so_line', 'in', self.ids), ('amount', '<=', 0.0)] + + @api.multi + def _analytic_compute_delivered_quantity(self): + """ Compute and write the delivered quantity of current SO lines, based on their related + analytic lines. + """ + # avoid recomputation if no SO lines concerned + if not self: + return False + + # group anaytic lines by product uom and so line + domain = self._analytic_compute_delivered_quantity_domain() + data = self.env['account.analytic.line'].read_group( + domain, + ['so_line', 'unit_amount', 'product_uom_id'], ['product_uom_id', 'so_line'], lazy=False + ) + + # convert uom and sum all unit_amount of analytic lines to get the delivered qty of SO lines + value_to_write = {} + for item in data: + if not item['product_uom_id']: + continue + so_line = self.browse(item['so_line'][0]) + value_to_write.setdefault(so_line, 0.0) + uom = self.env['product.uom'].browse(item['product_uom_id'][0]) + if so_line.product_uom.category_id == uom.category_id: + qty = uom._compute_quantity(item['unit_amount'], so_line.product_uom) + else: + qty = item['unit_amount'] + value_to_write[so_line] += qty + + # write the delivered quantity + for so_line, qty in value_to_write.items(): + so_line.write({'qty_delivered': qty}) + + return True diff --git a/addons/sale/models/sale_analytic.py b/addons/sale/models/sale_analytic.py deleted file mode 100644 index 96f2f896f44..00000000000 --- a/addons/sale/models/sale_analytic.py +++ /dev/null @@ -1,147 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models, _ -from odoo.exceptions import UserError - - -class SaleOrderLine(models.Model): - _inherit = "sale.order.line" - - @api.multi - def _compute_analytic(self, domain=None): - lines = {} - force_so_lines = self.env.context.get("force_so_lines") - if not domain: - if not self.ids and not force_so_lines: - return True - # To filter on analyic lines linked to an expense - expense_type_id = self.env.ref('account.data_account_type_expenses', raise_if_not_found=False) - expense_type_id = expense_type_id and expense_type_id.id - domain = [('so_line', 'in', self.ids), ('amount', '<=', 0.0)] - - data = self.env['account.analytic.line'].read_group( - domain, - ['so_line', 'unit_amount', 'product_uom_id'], ['product_uom_id', 'so_line'], lazy=False - ) - # If the unlinked analytic line was the last one on the SO line, the qty was not updated. - if force_so_lines: - for line in force_so_lines: - lines.setdefault(line, 0.0) - for d in data: - if not d['product_uom_id']: - continue - line = self.browse(d['so_line'][0]) - lines.setdefault(line, 0.0) - uom = self.env['product.uom'].browse(d['product_uom_id'][0]) - if line.product_uom.category_id == uom.category_id: - qty = uom._compute_quantity(d['unit_amount'], line.product_uom) - else: - qty = d['unit_amount'] - lines[line] += qty - - for line, qty in lines.items(): - line.qty_delivered = qty - return True - - -class AccountAnalyticLine(models.Model): - _inherit = "account.analytic.line" - so_line = fields.Many2one('sale.order.line', string='Sales Order Line') - - def _get_invoice_price(self, order): - if self.product_id.expense_policy == 'sales_price': - return self.product_id.with_context( - partner=order.partner_id.id, - date_order=order.date_order, - pricelist=order.pricelist_id.id, - uom=self.product_uom_id.id - ).price - if self.unit_amount == 0.0: - return 0.0 - - # Prevent unnecessary currency conversion that could be impacted by exchange rate - # fluctuations - if self.currency_id and self.amount_currency and self.currency_id == order.currency_id: - return abs(self.amount_currency / self.unit_amount) - - price_unit = abs(self.amount / self.unit_amount) - currency_id = self.company_id.currency_id - if currency_id and currency_id != order.currency_id: - price_unit = currency_id.compute(price_unit, order.currency_id) - return price_unit - - def _get_sale_order_line_vals(self, order, price): - - last_so_line = self.env['sale.order.line'].search([('order_id', '=', order.id)], order='sequence desc', limit=1) - last_sequence = last_so_line.sequence + 1 if last_so_line else 100 - - fpos = order.fiscal_position_id or order.partner_id.property_account_position_id - taxes = fpos.map_tax(self.product_id.taxes_id, self.product_id, order.partner_id) - - return { - 'order_id': order.id, - 'name': self.name, - 'sequence': last_sequence, - 'price_unit': price, - 'tax_id': [x.id for x in taxes], - 'discount': 0.0, - 'product_id': self.product_id.id, - 'product_uom': self.product_uom_id.id, - 'product_uom_qty': 0.0, - 'qty_delivered': self.unit_amount, - } - - def _get_sale_order_line(self, vals=None): - result = dict(vals or {}) - so_line = result.get('so_line', False) or self.so_line - if not so_line and self.account_id and self.product_id and (self.product_id.expense_policy != 'no'): - order_in_sale = self.env['sale.order'].search([('project_id', '=', self.account_id.id), ('state', '=', 'sale')], limit=1) - order = order_in_sale or self.env['sale.order'].search([('project_id', '=', self.account_id.id)], limit=1) - if not order: - return result - price = self._get_invoice_price(order) - so_lines = self.env['sale.order.line'].search([ - ('order_id', '=', order.id), - ('price_unit', '=', price), - ('product_id', '=', self.product_id.id)]) - - if so_lines: - result.update({'so_line': so_lines[0].id}) - else: - if order.state != 'sale': - raise UserError(_('The Sales Order %s linked to the Analytic Account must be validated before registering expenses.') % order.name) - order_line_vals = self._get_sale_order_line_vals(order, price) - if order_line_vals: - so_line = self.env['sale.order.line'].create(order_line_vals) - so_line._compute_tax_id() - result.update({'so_line': so_line.id}) - return result - - @api.multi - def write(self, values): - if self._context.get('create', False): - return super(AccountAnalyticLine, self).write(values) - - lines = super(AccountAnalyticLine, self).write(values) - for line in self: - res = line.sudo()._get_sale_order_line(vals=values) - super(AccountAnalyticLine, line).write(res) - - self.mapped('so_line').sudo()._compute_analytic() - return lines - - @api.model - def create(self, values): - line = super(AccountAnalyticLine, self).create(values) - res = line.sudo()._get_sale_order_line(vals=values) - line.with_context(create=True).write(res) - line.mapped('so_line').sudo()._compute_analytic() - return line - - @api.multi - def unlink(self): - so_lines = self.sudo().mapped('so_line') - res = super(AccountAnalyticLine, self).unlink() - so_lines.with_context(force_so_lines=so_lines)._compute_analytic() - return res diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py index 0f30f231aa1..4ac5a9faab8 100644 --- a/addons/sale_timesheet/models/account.py +++ b/addons/sale_timesheet/models/account.py @@ -31,7 +31,6 @@ class AccountAnalyticLine(models.Model): 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) @@ -40,12 +39,35 @@ class AccountAnalyticLine(models.Model): 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 - so_lines -= self.mapped('so_line') - if so_lines: - so_lines.with_context(force_so_lines=so_lines).sudo()._compute_analytic() return True + @api.multi + def _sale_postprocess(self, additionnal_so_lines=None): + """ Set the SO line before recomputed delivered quantity on SO line. + Only applied for timesheet lines, having no SO line set yet. + """ + self.filtered(lambda aal: not aal.so_line and aal.project_id)._timesheet_set_sale_order_line() + return super(AccountAnalyticLine, self)._sale_postprocess(additionnal_so_lines=additionnal_so_lines) + + @api.multi + def _timesheet_set_sale_order_line(self): + """ Automatically set the SO line on the analytic line, for the timesheet flow. It retrives + the SO line which has create the project (track_service='timesheet'). There is only one line + per SO like this (See constraint). + """ + for timesheet in self.filtered(lambda aal: not aal.so_line): + sol = self.env['sale.order.line'].search([ + ('order_id.project_id', '=', timesheet.account_id.id), + ('state', 'in', ('sale', 'done')), + ('product_id.track_service', '=', 'timesheet'), + ('product_id.type', '=', 'service') + ], limit=1) + if sol: + timesheet.write({ + 'so_line': sol.id, + 'product_id': sol.product_id.id, + }) + def _get_timesheet_values(self, values): result = {} values = values if values is not None else {} @@ -143,26 +165,3 @@ class AccountAnalyticLine(models.Model): result['timesheet_revenue'] = revenue result['timesheet_invoice_type'] = billable_type return result - - def _get_sale_order_line(self, vals=None): - result = dict(vals or {}) - if self.project_id: - if result.get('so_line'): - sol = self.env['sale.order.line'].browse([result['so_line']]) - else: - sol = self.so_line - if not sol: - sol = self.env['sale.order.line'].search([ - ('order_id.project_id', '=', self.account_id.id), - ('state', 'in', ('sale', 'done')), - ('product_id.track_service', '=', 'timesheet'), - ('product_id.type', '=', 'service')], - limit=1) - if sol: - result.update({ - 'so_line': sol.id, - 'product_id': sol.product_id.id, - }) - result.update(self._get_timesheet_values(result)) - - return super(AccountAnalyticLine, self)._get_sale_order_line(vals=result) diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index d8a79438898..37e2f4bf9fe 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -4,6 +4,7 @@ from odoo import api, fields, models, _ from odoo.exceptions import ValidationError +from odoo.osv import expression class SaleOrder(models.Model): @@ -142,14 +143,17 @@ class SaleOrderLine(models.Model): line._create_task() return line + def _timesheet_compute_delivered_quantity_domain(self): + # TODO JEM: avoid increment delivered for all AAL or just timesheet ? + # see nim commit https://github.com/odoo/odoo/commit/21fbb9776a5fbd1838b189f1f7cf8c5d40663e14 + so_line_ids = self.filtered(lambda sol: sol.product_id.service_type != 'manual').ids + return ['&', ('so_line', 'in', so_line_ids), ('project_id', '!=', False)] + @api.multi - def _compute_analytic(self, domain=None): - if not domain and self.ids: - # To filter on analyic lines linked to an expense - expense_type_id = self.env.ref('account.data_account_type_expenses', raise_if_not_found=False) - expense_type_id = expense_type_id and expense_type_id.id - domain = [('so_line', 'in', self.ids), '|', ('amount', '<=', 0.0), ('project_id', '!=', False)] - return super(SaleOrderLine, self)._compute_analytic(domain=domain) + def _analytic_compute_delivered_quantity_domain(self): + domain = super(SaleOrderLine, self)._analytic_compute_delivered_quantity_domain() + timesheet_domain = self._timesheet_compute_delivered_quantity_domain() + return expression.OR([domain, timesheet_domain]) def _convert_qty_company_hours(self): company_time_uom_id = self.env.user.company_id.project_time_mode_id From 5261cdceae58359ee08a0170fd5d27f4d84c39ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Maes?= Date: Thu, 29 Jun 2017 13:49:11 +0200 Subject: [PATCH 02/20] [REF] sale_timesheet: clean task creation from so line Make the code more readable and callable on a recordset. This commit prepare the so line to also generate a project (and not simply a task). --- addons/sale_timesheet/models/sale_order.py | 69 +++++++++++++++++----- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index 37e2f4bf9fe..a11e320a645 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -67,7 +67,7 @@ class SaleOrder(models.Model): order._create_analytic_account(prefix=line.product_id.default_code or None) order.project_id.project_create({'name': order.project_id.name}) break - order.order_line.filtered(lambda line: line._is_task())._create_task() + order.order_line.filtered(lambda line: line.is_service_task)._timesheet_find_task() return result @api.multi @@ -134,13 +134,19 @@ class SaleOrderLine(models.Model): _inherit = "sale.order.line" task_id = fields.Many2one('project.task', 'Task') + is_service_task = fields.Boolean("Will create a task", compute='_compute_is_service_task') + + @api.multi + def _compute_is_service_task(self): + for so_line in self: + so_line.is_service_task = so_line.product_id.type == 'service' and so_line.product_id.track_service == 'task' @api.model def create(self, values): line = super(SaleOrderLine, self).create(values) if line.state == 'sale' and not line.order_id.project_id and line.product_id.track_service in ['timesheet', 'task']: line.order_id._create_analytic_account() - line._create_task() + line._timesheet_find_task() return line def _timesheet_compute_delivered_quantity_domain(self): @@ -163,7 +169,7 @@ class SaleOrderLine(models.Model): planned_hours = self.product_uom_qty return planned_hours - def _get_project(self): + def _timesheet_find_project(self): Project = self.env['project.project'] project = self.product_id.with_context(force_company=self.company_id.id).project_id if not project: @@ -174,13 +180,13 @@ class SaleOrderLine(models.Model): account = self.order_id.project_id project = Project.search([('analytic_account_id', '=', account.id)], limit=1) if not project: - project_id = account.project_create({'name': account.name, 'use_tasks': True}) + project_id = account.project_create({'name': account.name}) project = Project.browse(project_id) return project - def _prepare_service_task_values(self): + def _timesheet_create_task_prepare_values(self): self.ensure_one() - project = self._get_project() + project = self._timesheet_find_project() planned_hours = self._convert_qty_company_hours() return { 'name': '%s:%s' % (self.order_id.name or '', self.product_id.name), @@ -193,12 +199,47 @@ class SaleOrderLine(models.Model): 'company_id': self.company_id.id, } - def _create_task(self): - for line in self: - task_values = line._prepare_service_task_values() - task = self.env['project.task'].create(task_values) - self.write({'task_id': task.id}) + @api.multi + def _timesheet_create_task(self): + """ Generate task for the given so line, and link it. - def _is_task(self): - self.ensure_one() - return self.product_id.type == 'service' and self.product_id.track_service == 'task' + :return a mapping with the so line id and its linked task + :rtype dict + """ + result = {} + for so_line in self: + # create task + values = so_line._timesheet_create_task_prepare_values() + task = self.env['project.task'].sudo().create(values) + so_line.write({'task_id': task.id}) + # post message on SO + msg_body = _("Task Created (%s): %s") % (so_line.product_id.name, task.id, task.name) + so_line.order_id.message_post(body=msg_body) + # post message on task + task_msg = _("This task has been created from: %s (%s)") % (so_line.order_id.id, so_line.order_id.name, so_line.product_id.name) + task.message_post(body=task_msg) + + result[so_line.id] = task + return result + + @api.multi + def _timesheet_find_task(self): + """ Find the task generated by the so lines. If no task linked, it will be + created automatically. + + :return a mapping with the so line id and its linked task + :rtype dict + """ + # one search for all so lines + tasks = self.env['project.task'].search([('sale_line_id', 'in', self.ids)]) + task_sol_mapping = {task.sale_line_id.id: task for task in tasks} + + result = {} + for so_line in self: + # If the SO was confirmed, cancelled, set to draft then confirmed, avoid creating a new task. + task = task_sol_mapping.get(so_line.id) + # If not found, create one task for the so line + if not task: + task = so_line._timesheet_create_task()[so_line.id] + result[so_line.id] = task + return result From 9e00dacded8ac9cd5a9fc9a34726908ff4691e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Maes?= Date: Fri, 30 Jun 2017 15:10:30 +0200 Subject: [PATCH 03/20] [IMP] sale_timesheet: so line create project As so line already generate task, and project for the SO sometimes, making it always create the project seems to be a good idea. Grouping all the logic is better for maintenability and readability. --- addons/sale_timesheet/models/product.py | 1 - addons/sale_timesheet/models/sale_order.py | 50 ++++++++++++++-------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py index b3035565c9b..2f3c86d562f 100644 --- a/addons/sale_timesheet/models/product.py +++ b/addons/sale_timesheet/models/product.py @@ -20,4 +20,3 @@ class ProductTemplate(models.Model): self.track_service = 'timesheet' else: self.track_service = 'manual' - diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index a11e320a645..da90a057137 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -58,16 +58,9 @@ class SaleOrder(models.Model): @api.multi def action_confirm(self): + """ On SO confirmation, some lines should generate a task or a project. """ result = super(SaleOrder, self).action_confirm() - for order in self: - if not order.project_project_id: - for line in order.order_line: - if line.product_id.track_service == 'timesheet': - if not order.project_id: - order._create_analytic_account(prefix=line.product_id.default_code or None) - order.project_id.project_create({'name': order.project_id.name}) - break - order.order_line.filtered(lambda line: line.is_service_task)._timesheet_find_task() + self.order_line._timesheet_service_generation() return result @api.multi @@ -133,22 +126,25 @@ class SaleOrder(models.Model): class SaleOrderLine(models.Model): _inherit = "sale.order.line" - task_id = fields.Many2one('project.task', 'Task') - is_service_task = fields.Boolean("Will create a task", compute='_compute_is_service_task') + task_id = fields.Many2one('project.task', 'Task', help="Task generated by the sales order item") + is_service = fields.Boolean("Is service", compute='_compute_is_service', help="Sales Order item should generate a task and/or a project, depending on the product settings.") @api.multi - def _compute_is_service_task(self): + def _compute_is_service(self): for so_line in self: - so_line.is_service_task = so_line.product_id.type == 'service' and so_line.product_id.track_service == 'task' + so_line.is_service = so_line.product_id.type == 'service' and so_line.product_id.track_service in ['task', 'timesheet'] @api.model def create(self, values): line = super(SaleOrderLine, self).create(values) - if line.state == 'sale' and not line.order_id.project_id and line.product_id.track_service in ['timesheet', 'task']: - line.order_id._create_analytic_account() - line._timesheet_find_task() + if line.state == 'sale': + line._timesheet_service_generation() return line + ########################################### + ### Analytic : auto recompute delivered quantity + ########################################### + def _timesheet_compute_delivered_quantity_domain(self): # TODO JEM: avoid increment delivered for all AAL or just timesheet ? # see nim commit https://github.com/odoo/odoo/commit/21fbb9776a5fbd1838b189f1f7cf8c5d40663e14 @@ -161,6 +157,10 @@ class SaleOrderLine(models.Model): timesheet_domain = self._timesheet_compute_delivered_quantity_domain() return expression.OR([domain, timesheet_domain]) + ########################################### + ## Service : Project and task generation + ########################################### + def _convert_qty_company_hours(self): company_time_uom_id = self.env.user.company_id.project_time_mode_id if self.product_uom.id != company_time_uom_id.id and self.product_uom.category_id.id == company_time_uom_id.category_id.id: @@ -170,18 +170,19 @@ class SaleOrderLine(models.Model): return planned_hours def _timesheet_find_project(self): + self.ensure_one() Project = self.env['project.project'] project = self.product_id.with_context(force_company=self.company_id.id).project_id if not project: # find the project corresponding to the analytic account of the sales order account = self.order_id.project_id if not account: - self.order_id._create_analytic_account() + self.order_id._create_analytic_account(prefix=self.product_id.default_code or None) account = self.order_id.project_id project = Project.search([('analytic_account_id', '=', account.id)], limit=1) if not project: - project_id = account.project_create({'name': account.name}) - project = Project.browse(project_id) + project_id = account.sudo().project_create({'name': account.name}) + project = Project.sudo().browse(project_id) return project def _timesheet_create_task_prepare_values(self): @@ -243,3 +244,14 @@ class SaleOrderLine(models.Model): task = so_line._timesheet_create_task()[so_line.id] result[so_line.id] = task return result + + @api.multi + def _timesheet_service_generation(self): + """ For service lines, create the task or the project. If already exists, it simply links + the existing one to the line. + """ + for so_line in self.filtered(lambda sol: sol.is_service): + if so_line.product_id.track_service == 'task': + so_line._timesheet_find_task() + if so_line.product_id.track_service == 'timesheet': + so_line._timesheet_find_project() From 1382f63da1d51a4a255a029c8fec542602e6e229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Maes?= Date: Fri, 30 Jun 2017 10:18:45 +0200 Subject: [PATCH 04/20] [IMP] sale: remove useless field Now, new view send the value, even if the field is in 'readonly' mode. No need to have a related field to allow the edition. --- addons/sale/models/sale.py | 4 ---- addons/sale/views/sale_views.xml | 6 +----- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index b8b7d4890f8..04a7fee45ad 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -110,9 +110,6 @@ class SaleOrder(models.Model): for order in self: order.order_line._compute_tax_id() - def _inverse_project_id(self): - self.project_id = self.related_project_id - name = fields.Char(string='Order Reference', required=True, copy=False, readonly=True, states={'draft': [('readonly', False)]}, index=True, default=lambda self: _('New')) origin = fields.Char(string='Source Document', help="Reference of the document that generated this sales order request.") client_order_ref = fields.Char(string='Customer Reference', copy=False) @@ -139,7 +136,6 @@ class SaleOrder(models.Model): pricelist_id = fields.Many2one('product.pricelist', string='Pricelist', required=True, readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="Pricelist for current sales order.") currency_id = fields.Many2one("res.currency", related='pricelist_id.currency_id', string="Currency", readonly=True, required=True) project_id = fields.Many2one('account.analytic.account', 'Analytic Account', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="The analytic account related to a sales order.", copy=False) - related_project_id = fields.Many2one('account.analytic.account', inverse='_inverse_project_id', related='project_id', string='Analytic Account', help="The analytic account related to a sales order.") order_line = fields.One2many('sale.order.line', 'order_id', string='Order Lines', states={'cancel': [('readonly', True)], 'done': [('readonly', True)]}, copy=True, auto_join=True) diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index 24e97c177a2..3f858a7ae1d 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -383,11 +383,7 @@ - - + From 0d50bf5d5b7b272bacf586d90187dd00b6afacdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Maes?= Date: Fri, 30 Jun 2017 10:38:38 +0200 Subject: [PATCH 05/20] [IMP] sale: rename 'project_id' into 'analytic_account_id' Since this field is an analytic account and not a project, its name should be more explicit. This commit makes the code more understandable. Naming things correctly improve developper's sleep. --- addons/sale/models/analytic.py | 4 ++-- addons/sale/models/sale.py | 6 +++--- addons/sale/report/report_all_channels_sales.py | 2 +- addons/sale/report/sale_report.py | 4 ++-- addons/sale/tests/test_sale_order.py | 2 +- addons/sale/views/sale_views.xml | 4 ++-- addons/sale/wizard/sale_make_invoice_advance.py | 2 +- addons/sale_expense/tests/test_sale_expense.py | 4 ++-- addons/sale_timesheet/models/account.py | 2 +- addons/sale_timesheet/models/sale_order.py | 12 ++++++------ 10 files changed, 21 insertions(+), 21 deletions(-) diff --git a/addons/sale/models/analytic.py b/addons/sale/models/analytic.py index fc0c25a3a11..cde205d5a98 100644 --- a/addons/sale/models/analytic.py +++ b/addons/sale/models/analytic.py @@ -106,9 +106,9 @@ class AccountAnalyticLine(models.Model): """ for analytic_line in self.sudo().filtered(lambda aal: not aal.so_line and aal.account_id and aal.product_id and aal.product_id.expense_policy != 'no'): # determine SO : first SO open linked to AA - sale_order = self.env['sale.order'].search([('project_id', '=', analytic_line.account_id.id), ('state', '=', 'sale')], limit=1) + sale_order = self.env['sale.order'].search([('analytic_account_id', '=', analytic_line.account_id.id), ('state', '=', 'sale')], limit=1) if not sale_order: - sale_order = self.env['sale.order'].search([('project_id', '=', analytic_line.account_id.id)], limit=1) + sale_order = self.env['sale.order'].search([('analytic_account_id', '=', analytic_line.account_id.id)], limit=1) if not sale_order: continue diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 04a7fee45ad..31d6e2053e4 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -135,7 +135,7 @@ class SaleOrder(models.Model): pricelist_id = fields.Many2one('product.pricelist', string='Pricelist', required=True, readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="Pricelist for current sales order.") currency_id = fields.Many2one("res.currency", related='pricelist_id.currency_id', string="Currency", readonly=True, required=True) - project_id = fields.Many2one('account.analytic.account', 'Analytic Account', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="The analytic account related to a sales order.", copy=False) + analytic_account_id = fields.Many2one('account.analytic.account', 'Analytic Account', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="The analytic account related to a sales order.", copy=False, oldname='project_id') order_line = fields.One2many('sale.order.line', 'order_id', string='Order Lines', states={'cancel': [('readonly', True)], 'done': [('readonly', True)]}, copy=True, auto_join=True) @@ -509,7 +509,7 @@ class SaleOrder(models.Model): 'company_id': order.company_id.id, 'partner_id': order.partner_id.id }) - order.project_id = analytic + order.analytic_account_id = analytic @api.multi def order_lines_layouted(self): @@ -891,7 +891,7 @@ class SaleOrderLine(models.Model): 'product_id': self.product_id.id or False, 'layout_category_id': self.layout_category_id and self.layout_category_id.id or False, 'invoice_line_tax_ids': [(6, 0, self.tax_id.ids)], - 'account_analytic_id': self.order_id.project_id.id, + 'account_analytic_id': self.order_id.analytic_account_id.id, 'analytic_tag_ids': [(6, 0, self.analytic_tag_ids.ids)], } return res diff --git a/addons/sale/report/report_all_channels_sales.py b/addons/sale/report/report_all_channels_sales.py index ed0219e2e0d..5e1f9eca28b 100644 --- a/addons/sale/report/report_all_channels_sales.py +++ b/addons/sale/report/report_all_channels_sales.py @@ -42,7 +42,7 @@ class PosSaleReport(models.Model): rp.country_id AS country_id, sol.price_subtotal / COALESCE (cr.rate, 1.0) AS price_subtotal, (sol.product_uom_qty / u.factor * u2.factor) as product_qty, - so.project_id AS analytic_account_id, + so.analytic_account_id AS analytic_account_id, so.team_id AS team_id FROM sale_order_line sol diff --git a/addons/sale/report/sale_report.py b/addons/sale/report/sale_report.py index 3e9905ec85b..42c6953f4dc 100644 --- a/addons/sale/report/sale_report.py +++ b/addons/sale/report/sale_report.py @@ -71,7 +71,7 @@ class SaleReport(models.Model): extract(epoch from avg(date_trunc('day',s.date_order)-date_trunc('day',s.create_date)))/(24*60*60)::decimal(16,2) as delay, t.categ_id as categ_id, s.pricelist_id as pricelist_id, - s.project_id as analytic_account_id, + s.analytic_account_id as analytic_account_id, s.team_id as team_id, p.product_tmpl_id, partner.country_id as country_id, @@ -112,7 +112,7 @@ class SaleReport(models.Model): s.state, s.company_id, s.pricelist_id, - s.project_id, + s.analytic_account_id, s.team_id, p.product_tmpl_id, partner.country_id, diff --git a/addons/sale/tests/test_sale_order.py b/addons/sale/tests/test_sale_order.py index 2b6c082b150..e0892afed10 100644 --- a/addons/sale/tests/test_sale_order.py +++ b/addons/sale/tests/test_sale_order.py @@ -127,7 +127,7 @@ class TestSaleOrder(TestSale): 'name': '', 'type': 'in_invoice', 'partner_id': inv_partner.id, - 'invoice_line_ids': [(0, 0, {'name': serv_cost.name, 'product_id': serv_cost.id, 'quantity': 2, 'uom_id': serv_cost.uom_id.id, 'price_unit': serv_cost.standard_price, 'account_analytic_id': so.project_id.id, 'account_id': account_income.id})], + 'invoice_line_ids': [(0, 0, {'name': serv_cost.name, 'product_id': serv_cost.id, 'quantity': 2, 'uom_id': serv_cost.uom_id.id, 'price_unit': serv_cost.standard_price, 'account_analytic_id': so.analytic_account_id.id, 'account_id': account_income.id})], 'account_id': account_payable.id, 'journal_id': journal.id, 'currency_id': company.currency_id.id, diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index 3f858a7ae1d..576c37b923f 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -383,7 +383,7 @@ - + @@ -429,7 +429,7 @@ - + diff --git a/addons/sale/wizard/sale_make_invoice_advance.py b/addons/sale/wizard/sale_make_invoice_advance.py index 01cf2225a95..838f038d9bc 100644 --- a/addons/sale/wizard/sale_make_invoice_advance.py +++ b/addons/sale/wizard/sale_make_invoice_advance.py @@ -107,7 +107,7 @@ class SaleAdvancePaymentInv(models.TransientModel): 'product_id': self.product_id.id, 'sale_line_ids': [(6, 0, [so_line.id])], 'invoice_line_tax_ids': [(6, 0, tax_ids)], - 'account_analytic_id': order.project_id.id or False, + 'account_analytic_id': order.analytic_account_id.id or False, })], 'currency_id': order.pricelist_id.currency_id.id, 'payment_term_id': order.payment_term_id.id, diff --git a/addons/sale_expense/tests/test_sale_expense.py b/addons/sale_expense/tests/test_sale_expense.py index 91a094bf901..1fd00aa2a68 100644 --- a/addons/sale_expense/tests/test_sale_expense.py +++ b/addons/sale_expense/tests/test_sale_expense.py @@ -40,7 +40,7 @@ class TestSaleExpense(TestSale): exp = self.env['hr.expense'].create({ 'name': 'Air Travel', 'product_id': prod_exp_1.id, - 'analytic_account_id': so.project_id.id, + 'analytic_account_id': so.analytic_account_id.id, 'unit_amount': 621.54, 'employee_id': employee.id, 'sheet_id': sheet.id @@ -67,7 +67,7 @@ class TestSaleExpense(TestSale): exp = self.env['hr.expense'].create({ 'name': 'Car Travel', 'product_id': prod_exp_2.id, - 'analytic_account_id': so.project_id.id, + 'analytic_account_id': so.analytic_account_id.id, 'product_uom_id': self.env.ref('product.product_uom_km').id, 'unit_amount': 0.15, 'quantity': 100, diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py index 4ac5a9faab8..064559515cd 100644 --- a/addons/sale_timesheet/models/account.py +++ b/addons/sale_timesheet/models/account.py @@ -57,7 +57,7 @@ class AccountAnalyticLine(models.Model): """ for timesheet in self.filtered(lambda aal: not aal.so_line): sol = self.env['sale.order.line'].search([ - ('order_id.project_id', '=', timesheet.account_id.id), + ('order_id.analytic_account_id', '=', timesheet.account_id.id), ('state', 'in', ('sale', 'done')), ('product_id.track_service', '=', 'timesheet'), ('product_id.type', '=', 'service') diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index da90a057137..92e154f7d78 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -19,10 +19,10 @@ class SaleOrder(models.Model): project_project_id = fields.Many2one('project.project', compute='_compute_project_project_id', string='Project associated to this sale') @api.multi - @api.depends('project_id.line_ids') + @api.depends('analytic_account_id.line_ids') def _compute_timesheet_ids(self): for order in self: - if order.project_id: + if order.analytic_account_id: order.timesheet_ids = self.env['account.analytic.line'].search( [('so_line', 'in', order.order_line.ids), ('amount', '<=', 0.0), @@ -39,10 +39,10 @@ class SaleOrder(models.Model): order.tasks_count = len(order.tasks_ids) @api.multi - @api.depends('project_id.project_ids') + @api.depends('analytic_account_id.project_ids') def _compute_project_project_id(self): for order in self: - order.project_project_id = self.env['project.project'].search([('analytic_account_id', '=', order.project_id.id)]) + order.project_project_id = self.env['project.project'].search([('analytic_account_id', '=', order.analytic_account_id.id)]) @api.multi @api.constrains('order_line') @@ -175,10 +175,10 @@ class SaleOrderLine(models.Model): project = self.product_id.with_context(force_company=self.company_id.id).project_id if not project: # find the project corresponding to the analytic account of the sales order - account = self.order_id.project_id + account = self.order_id.analytic_account_id if not account: self.order_id._create_analytic_account(prefix=self.product_id.default_code or None) - account = self.order_id.project_id + account = self.order_id.analytic_account_id project = Project.search([('analytic_account_id', '=', account.id)], limit=1) if not project: project_id = account.sudo().project_create({'name': account.name}) From 538e62e53a95c28cb5ca2be3cb708bb64ad59266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Maes?= Date: Mon, 10 Jul 2017 11:20:07 +0200 Subject: [PATCH 06/20] [IMP] sale_timesheet: product config for service The purpose is to simplify the product configuration. Adding detailled options about the service tracking (create task and/or project) make the setup less confusing for users. The invoice policy is replaced by "Invoice based" (only for services). We don't support "ordered quantities" with "manual" tracking anymore. The supported use cases are : - Ordered quantities (using timesheet) - Timesheet on task (delivered with timesheet) - Milestones (delivered manually) Also, even when selecting 'Milestones', the SO line can generate a task/project, but timesheeting on thoses objects will not change the delivered quantities on the SOL. So, a task can always be created whatever the service invoice policy of the product. Every generated task or project are linked to their SO line. But this SO line is only incremented (on delivered quantity) if its product 'service_type' is timesheet. --- addons/sale/models/product_template.py | 4 +- addons/sale/models/sale.py | 2 +- addons/sale/views/sale_views.xml | 2 +- addons/sale_expense/views/product_view.xml | 2 +- .../sale_timesheet/data/sale_service_demo.xml | 9 ++-- addons/sale_timesheet/models/account.py | 29 +----------- addons/sale_timesheet/models/product.py | 47 +++++++++++++++---- addons/sale_timesheet/models/project.py | 2 + addons/sale_timesheet/models/sale_order.py | 35 +++++++++----- .../sale_timesheet/tests/test_sale_service.py | 3 +- .../tests/test_timesheet_revenue.py | 6 ++- addons/sale_timesheet/views/product_views.xml | 30 ++++++++---- .../views/res_config_settings_views.xml | 3 ++ .../test_main_flows/static/src/js/tour.js | 6 +-- 14 files changed, 106 insertions(+), 74 deletions(-) diff --git a/addons/sale/models/product_template.py b/addons/sale/models/product_template.py index 4a7a58f05e6..7cdfc9817cf 100644 --- a/addons/sale/models/product_template.py +++ b/addons/sale/models/product_template.py @@ -8,11 +8,11 @@ from odoo.addons.base.res.res_partner import WARNING_MESSAGE, WARNING_HELP class ProductTemplate(models.Model): _inherit = 'product.template' - track_service = fields.Selection([('manual', 'Manually set quantities on order')], string='Track Service', + service_type = fields.Selection([('manual', 'Manually set quantities on order')], string='Track Service', help="Manually set quantities on order: Invoice based on the manually entered quantity, without creating an analytic account.\n" "Timesheets on contract: Invoice based on the tracked hours on the related timesheet.\n" "Create a task and track hours: Create a task on the sales order validation and track the work hours.", - default='manual') + default='manual', oldname='track_service') sale_line_warn = fields.Selection(WARNING_MESSAGE, 'Sales Order Line', help=WARNING_HELP, required=True, default="no-message") sale_line_warn_msg = fields.Text('Message for Sales Order Line') expense_policy = fields.Selection( diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 31d6e2053e4..cc5d909db85 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -659,7 +659,7 @@ class SaleOrderLine(models.Model): @api.depends('product_id.invoice_policy', 'order_id.state') def _compute_qty_delivered_updateable(self): for line in self: - line.qty_delivered_updateable = (line.order_id.state == 'sale') and (line.product_id.track_service == 'manual') and (line.product_id.expense_policy == 'no') + line.qty_delivered_updateable = (line.order_id.state == 'sale') and (line.product_id.service_type == 'manual') and (line.product_id.expense_policy == 'no') @api.depends('invoice_lines', 'invoice_lines.price_total', diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index 576c37b923f..cded496290b 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -696,7 +696,7 @@ - + diff --git a/addons/sale_expense/views/product_view.xml b/addons/sale_expense/views/product_view.xml index 90539c2fade..2f27621a522 100644 --- a/addons/sale_expense/views/product_view.xml +++ b/addons/sale_expense/views/product_view.xml @@ -5,7 +5,7 @@ product.template - + diff --git a/addons/sale_timesheet/data/sale_service_demo.xml b/addons/sale_timesheet/data/sale_service_demo.xml index cfddb602bc8..96aadd83975 100644 --- a/addons/sale_timesheet/data/sale_service_demo.xml +++ b/addons/sale_timesheet/data/sale_service_demo.xml @@ -2,11 +2,13 @@ - timesheet + timesheet + project_only Support Contract (on timesheet) - timesheet + timesheet + project_only @@ -27,7 +29,8 @@ - task + timesheet + task_global_project diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py index 064559515cd..d6da4910a36 100644 --- a/addons/sale_timesheet/models/account.py +++ b/addons/sale_timesheet/models/account.py @@ -41,33 +41,6 @@ class AccountAnalyticLine(models.Model): return True - @api.multi - def _sale_postprocess(self, additionnal_so_lines=None): - """ Set the SO line before recomputed delivered quantity on SO line. - Only applied for timesheet lines, having no SO line set yet. - """ - self.filtered(lambda aal: not aal.so_line and aal.project_id)._timesheet_set_sale_order_line() - return super(AccountAnalyticLine, self)._sale_postprocess(additionnal_so_lines=additionnal_so_lines) - - @api.multi - def _timesheet_set_sale_order_line(self): - """ Automatically set the SO line on the analytic line, for the timesheet flow. It retrives - the SO line which has create the project (track_service='timesheet'). There is only one line - per SO like this (See constraint). - """ - for timesheet in self.filtered(lambda aal: not aal.so_line): - sol = self.env['sale.order.line'].search([ - ('order_id.analytic_account_id', '=', timesheet.account_id.id), - ('state', 'in', ('sale', 'done')), - ('product_id.track_service', '=', 'timesheet'), - ('product_id.type', '=', 'service') - ], limit=1) - if sol: - timesheet.write({ - 'so_line': sol.id, - 'product_id': sol.product_id.id, - }) - def _get_timesheet_values(self, values): result = {} values = values if values is not None else {} @@ -144,7 +117,7 @@ class AccountAnalyticLine(models.Model): if so_line.product_id.invoice_policy == 'delivery': revenue = analytic_account.currency_id.round(unit_amount * sale_price * (1-(so_line.discount/100))) billable_type = 'billable_time' - elif so_line.product_id.invoice_policy == 'order' and so_line.product_id.track_service == 'task': + elif so_line.product_id.invoice_policy == 'order': quantity_hour = unit_amount if so_line.product_uom.category_id == timesheet_uom.category_id: quantity_hour = so_line.product_uom._compute_quantity(so_line.product_uom_qty, timesheet_uom) diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py index 2f3c86d562f..af71631227f 100644 --- a/addons/sale_timesheet/models/product.py +++ b/addons/sale_timesheet/models/product.py @@ -7,16 +7,43 @@ from odoo import api, fields, models class ProductTemplate(models.Model): _inherit = 'product.template' - track_service = fields.Selection(selection_add=[ + service_policy = fields.Selection([ + ('ordered_timesheet', 'Ordered quantities'), + ('delivered_timesheet', 'Timesheets on tasks'), + ('delivered_manual', 'Milestones (manually set quantities on order)') + ], string="Invoice based on", compute='_compute_service_policy', inverse='_inverse_service_policy') + service_type = fields.Selection(selection_add=[ ('timesheet', 'Timesheets on project (one fare per SO/Project)'), - ('task', 'Create a task per order line to track hours')]) + ]) + service_tracking = fields.Selection([ + ('no', 'Don\'t create task'), + ('task_global_project', 'Create a task in an existing project'), + ('task_new_project', 'Create a task in a new project'), + ('project_only', 'Create a new project but no task'), + ], string="Service Tracking", default="no", help="On Sales order confirmation, this product can generate project and/or task. From thoses, you can track the service you are selling.") project_id = fields.Many2one( - 'project.project', 'Project', company_dependent=True, - help='Create a task under this project on sales order validation. This setting must be set for each company.') + 'project.project', 'Project', company_dependent=True, domain=[('sale_line_id', '=', False)], + help='Select a non billable project on which tasks can be created. This setting must be set for each company.') - @api.onchange('type') - def _onchange_type(self): - if self.type == 'service': - self.track_service = 'timesheet' - else: - self.track_service = 'manual' + @api.depends('invoice_policy', 'service_type') + def _compute_service_policy(self): + for product in self: + policy = 'ordered_timesheet' + if product.invoice_policy == 'delivery': + policy = 'delivered_manual' if product.service_type == 'manual' else 'delivered_timesheet' + product.service_policy = policy + + def _inverse_service_policy(self): + for product in self: + policy = product.service_policy + if product.service_policy == 'ordered_timesheet': + product.invoice_policy = 'order' + product.service_type = 'timesheet' + else: + product.invoice_policy = 'delivery' + product.service_type = 'manual' if policy == 'delivered_manual' else 'timesheet' + + @api.onchange('service_tracking') + def _onchange_service_tracking(self): + if self.service_tracking != 'task_global_project': + self.project_id = False diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 214cab4cf39..8adeec58b06 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -8,6 +8,8 @@ from odoo.exceptions import ValidationError class Project(models.Model): _inherit = 'project.project' + sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Line', readonly=True, help="Sale order line from which the project has been created. Used for tracability.") + @api.multi def action_view_timesheet(self): self.ensure_one() diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index 92e154f7d78..d063126a67f 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -45,16 +45,13 @@ class SaleOrder(models.Model): order.project_project_id = self.env['project.project'].search([('analytic_account_id', '=', order.analytic_account_id.id)]) @api.multi - @api.constrains('order_line') - def _check_multi_timesheet(self): + @api.depends('order_line.product_id', 'project_project_id') + def _compute_project_ids(self): for order in self: - count = 0 - for line in order.order_line: - if line.product_id.track_service == 'timesheet': - count += 1 - if count > 1: - raise ValidationError(_("You can use only one product on timesheet within the same sales order. You should split your order to include only one contract based on time and material.")) - return {} + projects = order.order_line.mapped('product_id.project_id') + if order.project_project_id: + projects |= order.project_project_id + order.project_ids = projects @api.multi def action_confirm(self): @@ -130,9 +127,10 @@ class SaleOrderLine(models.Model): is_service = fields.Boolean("Is service", compute='_compute_is_service', help="Sales Order item should generate a task and/or a project, depending on the product settings.") @api.multi + @api.depends('product_id.type') def _compute_is_service(self): for so_line in self: - so_line.is_service = so_line.product_id.type == 'service' and so_line.product_id.track_service in ['task', 'timesheet'] + so_line.is_service = so_line.product_id.type == 'service' @api.model def create(self, values): @@ -181,8 +179,14 @@ class SaleOrderLine(models.Model): account = self.order_id.analytic_account_id project = Project.search([('analytic_account_id', '=', account.id)], limit=1) if not project: - project_id = account.sudo().project_create({'name': account.name}) + project_id = account.sudo().project_create({ + 'name': account.name, + 'allow_timesheets': self.product_id.service_type == 'timesheet', + }) project = Project.sudo().browse(project_id) + # set the SO line origin if product should create project + if not project.sale_line_id and self.product_id.service_tracking in ['task_new_project', 'project_only']: + project.write({'sale_line_id': self.id}) return project def _timesheet_create_task_prepare_values(self): @@ -251,7 +255,12 @@ class SaleOrderLine(models.Model): the existing one to the line. """ for so_line in self.filtered(lambda sol: sol.is_service): - if so_line.product_id.track_service == 'task': + # create task + if so_line.product_id.service_tracking == 'task_global_project': so_line._timesheet_find_task() - if so_line.product_id.track_service == 'timesheet': + # create project + if so_line.product_id.service_tracking == 'project_only': so_line._timesheet_find_project() + # create project and task + if so_line.product_id.service_tracking == 'task_new_project': + so_line._timesheet_find_task() diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py index 755a4946636..c2f6f34c495 100644 --- a/addons/sale_timesheet/tests/test_sale_service.py +++ b/addons/sale_timesheet/tests/test_sale_service.py @@ -49,7 +49,8 @@ class TestSaleService(TestSale): 'uom_id': self.env.ref('product.product_uom_hour').id, 'uom_po_id': self.env.ref('product.product_uom_hour').id, 'default_code': 'SERV-DELI', - 'track_service': 'task', + 'service_type': 'timesheet', + 'service_tracking': 'task_global_project', 'project_id': project.id }) diff --git a/addons/sale_timesheet/tests/test_timesheet_revenue.py b/addons/sale_timesheet/tests/test_timesheet_revenue.py index 19dd8a6f3c8..4e72ba3c9a2 100644 --- a/addons/sale_timesheet/tests/test_timesheet_revenue.py +++ b/addons/sale_timesheet/tests/test_timesheet_revenue.py @@ -33,7 +33,8 @@ class TestSaleTimesheet(TestSale): 'uom_id': self.env.ref('product.product_uom_hour').id, 'uom_po_id': self.env.ref('product.product_uom_hour').id, 'default_code': 'SERV-DELI', - 'track_service': 'task', + 'service_type': 'timesheet', + 'service_tracking': 'task_global_project', 'project_id': self.project.id }) @@ -46,7 +47,8 @@ class TestSaleTimesheet(TestSale): 'uom_id': self.env.ref('product.product_uom_hour').id, 'uom_po_id': self.env.ref('product.product_uom_hour').id, 'default_code': 'SERV-ORDER', - 'track_service': 'task', + 'service_type': 'timesheet', + 'service_tracking': 'task_global_project', 'project_id': self.project.id }) diff --git a/addons/sale_timesheet/views/product_views.xml b/addons/sale_timesheet/views/product_views.xml index 0c9903233a3..15704930b60 100644 --- a/addons/sale_timesheet/views/product_views.xml +++ b/addons/sale_timesheet/views/product_views.xml @@ -5,14 +5,17 @@ product.template - + False - {'invisible': [('type','!=','service')]} + {'invisible': [('type','==','service')]} - - + + + + + @@ -24,8 +27,9 @@ - - + + + @@ -35,7 +39,7 @@ product.template form tree,form - {'search_default_product_time_based':1, 'default_type': 'service', 'default_invoice_policy': 'delivery'} + {'search_default_product_time_based':1, 'default_type': 'service', 'default_invoice_policy': 'delivery', 'default_service_type': 'timesheet'} @@ -43,7 +47,15 @@ product.template form tree,form - {'search_default_product_service_fixed':1, 'default_type': 'service', 'default_invoice_policy': 'order'} + {'search_default_product_service_fixed':1, 'default_type': 'service', 'default_invoice_policy': 'order', 'default_service_type': 'timesheet'} + + + + Products + product.template + form + tree,form + {'search_default_product_service_milestone':1, 'default_type': 'service', 'default_invoice_policy': 'delivery', 'default_service_type': 'manual'} diff --git a/addons/sale_timesheet/views/res_config_settings_views.xml b/addons/sale_timesheet/views/res_config_settings_views.xml index 7315c9ce029..bcc5b925374 100644 --- a/addons/sale_timesheet/views/res_config_settings_views.xml +++ b/addons/sale_timesheet/views/res_config_settings_views.xml @@ -15,6 +15,9 @@
+
+
diff --git a/odoo/addons/test_main_flows/static/src/js/tour.js b/odoo/addons/test_main_flows/static/src/js/tour.js index 749cb958dfb..d3a10dabda8 100644 --- a/odoo/addons/test_main_flows/static/src/js/tour.js +++ b/odoo/addons/test_main_flows/static/src/js/tour.js @@ -212,11 +212,11 @@ tour.register('main_flow_tour', { content: _t('Go to invoicing tab'), position: 'bottom', }, { - trigger: '.o_field_widget[name=invoice_policy] .o_radio_input[data-value="delivery"]', - content: _t('Change invoicing policy'), + trigger: '.o_field_widget[name=service_policy] .o_radio_input[data-value="delivered_timesheet"]', + content: _t('Change service policy'), position: 'left', }, { - trigger: '.o_field_widget[name=track_service] input[data-value="task"]', + trigger: '.o_field_widget[name=service_tracking] input[data-value="task_global_project"]', content: _t('Change track service'), position: 'left', }, { From 38b4a95940810f0ac86f256db52cf1e3cf720451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Maes?= Date: Tue, 18 Jul 2017 11:57:57 +0200 Subject: [PATCH 07/20] [IMP] sale_timesheet: remove duplicate field definition The field `project_time_mode_id` on res.company is already defined in project module. This came from the good old days where timesheet was not totally depending on project. In sale_timesheet, the field was redefined, and a hack with xml id was done. It is time to remove this crap. --- addons/sale_timesheet/__manifest__.py | 1 - .../sale_timesheet/data/sale_timesheet_data.xml | 16 ---------------- addons/sale_timesheet/models/__init__.py | 1 - addons/sale_timesheet/models/res_company.py | 14 -------------- 4 files changed, 32 deletions(-) delete mode 100644 addons/sale_timesheet/data/sale_timesheet_data.xml delete mode 100644 addons/sale_timesheet/models/res_company.py diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py index 8bc94cdffcf..e65f7580af7 100644 --- a/addons/sale_timesheet/__manifest__.py +++ b/addons/sale_timesheet/__manifest__.py @@ -16,7 +16,6 @@ have real delivered quantities in sales orders. 'website': 'https://www.odoo.com/page/warehouse', 'depends': ['sale_management', 'hr_timesheet'], 'data': [ - 'data/sale_timesheet_data.xml', 'views/account_invoice_views.xml', 'views/sale_order_views.xml', 'views/product_views.xml', diff --git a/addons/sale_timesheet/data/sale_timesheet_data.xml b/addons/sale_timesheet/data/sale_timesheet_data.xml deleted file mode 100644 index f0d5148a754..00000000000 --- a/addons/sale_timesheet/data/sale_timesheet_data.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - ir.model.fields - sale_timesheet - project_time_mode_id_duplicate_xmlid - True - - - - diff --git a/addons/sale_timesheet/models/__init__.py b/addons/sale_timesheet/models/__init__.py index cd36b996ae6..d22baec4d49 100644 --- a/addons/sale_timesheet/models/__init__.py +++ b/addons/sale_timesheet/models/__init__.py @@ -6,5 +6,4 @@ from . import account_invoice from . import hr from . import product from . import project -from . import res_company from . import sale_order diff --git a/addons/sale_timesheet/models/res_company.py b/addons/sale_timesheet/models/res_company.py deleted file mode 100644 index 19d5ce45ea0..00000000000 --- a/addons/sale_timesheet/models/res_company.py +++ /dev/null @@ -1,14 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import models, api, fields - - -class ResCompany(models.Model): - _inherit = 'res.company' - - @api.model - def _get_default_uom_id(self): - return self.env.ref("product.product_uom_hour", raise_if_not_found=False) - - project_time_mode_id = fields.Many2one('product.uom', 'Timesheet UoM', default=_get_default_uom_id) From 3890f8d69463fe1920b161c0dd35b4bdaf26764e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Maes?= Date: Thu, 13 Jul 2017 08:56:22 +0200 Subject: [PATCH 08/20] [REF] sale_timesheet: test suite improvements Tests of sale_timesheet were using many demo data. This is a practice we want to avoid. A common class setting up test data (product service, partner, ...) is created to help writting good test cases. All tests has been adapted, moved, and some were added to cover the new flow (new product configuration). --- addons/sale_timesheet/tests/__init__.py | 1 + addons/sale_timesheet/tests/common.py | 265 +++++++++++++++ .../sale_timesheet/tests/test_sale_service.py | 98 ++++-- .../tests/test_sale_timesheet.py | 305 +++++++++++++----- .../tests/test_timesheet_revenue.py | 5 +- 5 files changed, 560 insertions(+), 114 deletions(-) create mode 100644 addons/sale_timesheet/tests/common.py diff --git a/addons/sale_timesheet/tests/__init__.py b/addons/sale_timesheet/tests/__init__.py index 9b3932ef5fb..c18361894ff 100644 --- a/addons/sale_timesheet/tests/__init__.py +++ b/addons/sale_timesheet/tests/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import common from . import test_sale_timesheet from . import test_sale_service from . import test_timesheet_revenue diff --git a/addons/sale_timesheet/tests/common.py b/addons/sale_timesheet/tests/common.py new file mode 100644 index 00000000000..e60291c61bb --- /dev/null +++ b/addons/sale_timesheet/tests/common.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import common + + +class CommonTest(common.SavepointCase): + + @classmethod + def setUpClass(cls): + super(CommonTest, cls).setUpClass() + + # Create accounts + cls.account_debit = cls.env['account.account'].create({ + 'code': 'X1012', + 'name': 'Debtors - (test)', + 'reconcile': True, + 'user_type_id': cls.env.ref('account.data_account_type_receivable').id, + }) + cls.account_credit = cls.env['account.account'].create({ + 'code': 'X1111', + 'name': 'Creditors - (test)', + 'reconcile': True, + 'user_type_id': cls.env.ref('account.data_account_type_payable').id, + }) + cls.account_sale = cls.env['account.account'].create({ + 'code': 'X2020', + 'name': 'Product Sales - (test)', + 'reconcile': True, + 'user_type_id': cls.env.ref('account.data_account_type_revenue').id, + }) + + # Create project + cls.project_global = cls.env['project.project'].create({ + 'name': 'Project for selling timesheets', + 'allow_timesheets': True, + }) + + # Create service products + + # -- ordered quantities (ordered, timesheet) + cls.product_order_timesheet1 = cls.env['product.product'].create({ + 'name': "Service Ordered, create no task", + 'standard_price': 11, + 'list_price': 13, + 'type': 'service', + 'invoice_policy': 'order', + 'uom_id': cls.env.ref('product.product_uom_hour').id, + 'uom_po_id': cls.env.ref('product.product_uom_hour').id, + 'default_code': 'SERV-ORDERED1', + 'service_type': 'timesheet', + 'service_tracking': 'no', + 'project_id': False, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + cls.product_order_timesheet2 = cls.env['product.product'].create({ + 'name': "Service Ordered, create task in global project", + 'standard_price': 30, + 'list_price': 90, + 'type': 'service', + 'invoice_policy': 'order', + 'uom_id': cls.env.ref('product.product_uom_hour').id, + 'uom_po_id': cls.env.ref('product.product_uom_hour').id, + 'default_code': 'SERV-ORDERED2', + 'service_type': 'timesheet', + 'service_tracking': 'task_global_project', + 'project_id': cls.project_global.id, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + cls.product_order_timesheet3 = cls.env['product.product'].create({ + 'name': "Service Ordered, create task in new project", + 'standard_price': 10, + 'list_price': 20, + 'type': 'service', + 'invoice_policy': 'order', + 'uom_id': cls.env.ref('product.product_uom_hour').id, + 'uom_po_id': cls.env.ref('product.product_uom_hour').id, + 'default_code': 'SERV-ORDERED3', + 'service_type': 'timesheet', + 'service_tracking': 'task_new_project', + 'project_id': False, # will create a project + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + cls.product_order_timesheet4 = cls.env['product.product'].create({ + 'name': "Service Ordered, create project only", + 'standard_price': 15, + 'list_price': 30, + 'type': 'service', + 'invoice_policy': 'order', + 'uom_id': cls.env.ref('product.product_uom_hour').id, + 'uom_po_id': cls.env.ref('product.product_uom_hour').id, + 'default_code': 'SERV-ORDERED4', + 'service_type': 'timesheet', + 'service_tracking': 'project_only', + 'project_id': False, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + + # -- timesheet on tasks (delivered, timesheet) + cls.product_delivery_timesheet1 = cls.env['product.product'].create({ + 'name': "Service delivered, create no task", + 'standard_price': 11, + 'list_price': 13, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': cls.env.ref('product.product_uom_hour').id, + 'uom_po_id': cls.env.ref('product.product_uom_hour').id, + 'default_code': 'SERV-DELI1', + 'service_type': 'timesheet', + 'service_tracking': 'no', + 'project_id': False, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + cls.product_delivery_timesheet2 = cls.env['product.product'].create({ + 'name': "Service delivered, create task in global project", + 'standard_price': 30, + 'list_price': 90, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': cls.env.ref('product.product_uom_hour').id, + 'uom_po_id': cls.env.ref('product.product_uom_hour').id, + 'default_code': 'SERV-DELI2', + 'service_type': 'timesheet', + 'service_tracking': 'task_global_project', + 'project_id': cls.project_global.id, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + cls.product_delivery_timesheet3 = cls.env['product.product'].create({ + 'name': "Service delivered, create task in new project", + 'standard_price': 10, + 'list_price': 20, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': cls.env.ref('product.product_uom_hour').id, + 'uom_po_id': cls.env.ref('product.product_uom_hour').id, + 'default_code': 'SERV-DELI3', + 'service_type': 'timesheet', + 'service_tracking': 'task_new_project', + 'project_id': False, # will create a project + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + cls.product_delivery_timesheet4 = cls.env['product.product'].create({ + 'name': "Service delivered, create project only", + 'standard_price': 15, + 'list_price': 30, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': cls.env.ref('product.product_uom_hour').id, + 'uom_po_id': cls.env.ref('product.product_uom_hour').id, + 'default_code': 'SERV-DELI4', + 'service_type': 'timesheet', + 'service_tracking': 'project_only', + 'project_id': False, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + + # -- milestons (delivered, manual) + cls.product_delivery_manual1 = cls.env['product.product'].create({ + 'name': "Service delivered, create no task", + 'standard_price': 11, + 'list_price': 13, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': cls.env.ref('product.product_uom_hour').id, + 'uom_po_id': cls.env.ref('product.product_uom_hour').id, + 'default_code': 'SERV-DELI1', + 'service_type': 'manual', + 'service_tracking': 'no', + 'project_id': False, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + cls.product_delivery_manual2 = cls.env['product.product'].create({ + 'name': "Service delivered, create task in global project", + 'standard_price': 30, + 'list_price': 90, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': cls.env.ref('product.product_uom_hour').id, + 'uom_po_id': cls.env.ref('product.product_uom_hour').id, + 'default_code': 'SERV-DELI2', + 'service_type': 'manual', + 'service_tracking': 'task_global_project', + 'project_id': cls.project_global.id, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + cls.product_delivery_manual3 = cls.env['product.product'].create({ + 'name': "Service delivered, create task in new project", + 'standard_price': 10, + 'list_price': 20, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': cls.env.ref('product.product_uom_hour').id, + 'uom_po_id': cls.env.ref('product.product_uom_hour').id, + 'default_code': 'SERV-DELI3', + 'service_type': 'manual', + 'service_tracking': 'task_new_project', + 'project_id': False, # will create a project + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + cls.product_delivery_manual4 = cls.env['product.product'].create({ + 'name': "Service delivered, create project only", + 'standard_price': 15, + 'list_price': 30, + 'type': 'service', + 'invoice_policy': 'delivery', + 'uom_id': cls.env.ref('product.product_uom_hour').id, + 'uom_po_id': cls.env.ref('product.product_uom_hour').id, + 'default_code': 'SERV-DELI4', + 'service_type': 'manual', + 'service_tracking': 'project_only', + 'project_id': False, + 'taxes_id': False, + 'property_account_income_id': cls.account_sale.id, + }) + + # Create pricelists + cls.pricelist_usd = cls.env['product.pricelist'].create({ + 'name': 'USD pricelist', + 'active': True, + 'currency_id': cls.env.ref('base.USD').id, + 'company_id': cls.env.user.company_id.id, + }) + cls.pricelist_eur = cls.env['product.pricelist'].create({ + 'name': 'EUR pricelist', + 'active': True, + 'currency_id': cls.env.ref('base.EUR').id, + 'company_id': cls.env.user.company_id.id, + }) + + # Create partners + cls.partner_usd = cls.env['res.partner'].create({ + 'name': 'Cool Partner in USD', + 'email': 'partner.usd@test.com', + 'property_product_pricelist': cls.pricelist_usd.id, + 'property_account_payable_id': cls.account_credit.id, + 'property_account_receivable_id': cls.account_debit.id, + }) + cls.partner_eur = cls.env['res.partner'].create({ + 'name': 'Cool partner in EUR', + 'email': 'partner.eur@test.com', + 'property_product_pricelist': cls.pricelist_eur.id, + 'property_account_payable_id': cls.account_credit.id, + 'property_account_receivable_id': cls.account_debit.id, + }) + + # Create employees + cls.employee_user = cls.env['hr.employee'].create({ + 'name': 'Employee User', + 'timesheet_cost': 15, + }) + cls.employee_manager = cls.env['hr.employee'].create({ + 'name': 'Employee Manager', + 'timesheet_cost': 45, + }) diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py index c2f6f34c495..5e92fe92fa2 100644 --- a/addons/sale_timesheet/tests/test_sale_service.py +++ b/addons/sale_timesheet/tests/test_sale_service.py @@ -1,43 +1,51 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo.addons.sale.tests.test_sale_common import TestSale + +from odoo.addons.sale_timesheet.tests.common import CommonTest -class TestSaleService(TestSale): +class TestSaleService(CommonTest): + """ This test suite provide checks for miscellaneous small things. """ def test_sale_service(self): - """ Test task creation when confirming a so with the corresponding product """ - prod_task = self.env.ref('product.product_product_1') - so_vals = { - 'partner_id': self.partner.id, - 'partner_invoice_id': self.partner.id, - 'partner_shipping_id': self.partner.id, - 'order_line': [(0, 0, {'name': prod_task.name, 'product_id': prod_task.id, 'product_uom_qty': 50, 'product_uom': prod_task.uom_id.id, 'price_unit': prod_task.list_price})], - 'pricelist_id': self.env.ref('product.list0').id, + """ Test task creation when confirming a sale_order with the corresponding product """ + sale_order_vals = { + 'partner_id': self.partner_usd.id, + 'partner_invoice_id': self.partner_usd.id, + 'partner_shipping_id': self.partner_usd.id, + 'order_line': [(0, 0, { + '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 + }), + ], + 'pricelist_id': self.pricelist_usd.id, } - so = self.env['sale.order'].create(so_vals) - so.action_confirm() - self.assertEqual(so.invoice_status, 'no', 'Sale Service: there should be nothing to invoice after validation') + sale_order = self.env['sale.order'].create(sale_order_vals) + sale_order.action_confirm() + self.assertEqual(sale_order.invoice_status, 'no', 'Sale Service: there should be nothing to invoice after validation') # check task creation - project = self.env.ref('sale_timesheet.project_GAP') - task = project.task_ids.filtered(lambda t: t.name == '%s:%s' % (so.name, prod_task.name)) + project = self.project_global + task = project.task_ids.filtered(lambda t: t.name == '%s:%s' % (sale_order.name, self.product_delivery_timesheet2.name)) self.assertTrue(task, 'Sale Service: task is not created') - self.assertEqual(task.partner_id, so.partner_id, 'Sale Service: customer should be the same on task and on SO') + self.assertEqual(task.partner_id, sale_order.partner_id, 'Sale Service: customer should be the same on task and on SO') # register timesheet on task self.env['account.analytic.line'].create({ 'name': 'Test Line', 'project_id': project.id, 'task_id': task.id, 'unit_amount': 50, - 'user_id': self.manager.id, + 'employee_id': self.employee_manager.id, }) - self.assertEqual(so.invoice_status, 'to invoice', 'Sale Service: there should be something to invoice after registering timesheets') - so.action_invoice_create() - line = so.order_line + self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Service: there should be sale_ordermething to invoice after registering timesheets') + sale_order.action_invoice_create() + line = sale_order.order_line self.assertTrue(line.product_uom_qty == line.qty_delivered == line.qty_invoiced, 'Sale Service: line should be invoiced completely') - self.assertEqual(so.invoice_status, 'invoiced', 'Sale Service: SO should be invoiced') - self.assertEqual(so.tasks_count, 1, "A task should have been created on SO confirmation.") + self.assertEqual(sale_order.invoice_status, 'invoiced', 'Sale Service: SO should be invoiced') + self.assertEqual(sale_order.tasks_count, 1, "A task should have been created on SO confirmation.") # Add a line on the confirmed SO, and it should generate a new task directly product_service_task = self.env['product.product'].create({ @@ -60,7 +68,49 @@ class TestSaleService(TestSale): 'product_uom_qty': 10, 'product_uom': product_service_task.uom_id.id, 'price_unit': product_service_task.list_price, - 'order_id': so.id, + 'order_id': sale_order.id, }) - self.assertEqual(so.tasks_count, 2, "Adding a new service line on a confirmer SO should create a new task.") + self.assertEqual(sale_order.tasks_count, 2, "Adding a new service line on a confirmer SO should create a new task.") + + def test_timesheet_uom(self): + """ Test timesheet invoicing and uom conversion """ + # create SO and confirm it + uom_days = self.env.ref('product.product_uom_day') + sale_order = self.env['sale.order'].create({ + 'partner_id': self.partner_usd.id, + 'partner_invoice_id': self.partner_usd.id, + 'partner_shipping_id': self.partner_usd.id, + 'order_line': [ + (0, 0, { + 'name': self.product_delivery_timesheet3.name, + 'product_id': self.product_delivery_timesheet3.id, + 'product_uom_qty': 5, + 'product_uom': uom_days.id, + 'price_unit': self.product_delivery_timesheet3.list_price + }) + ], + 'pricelist_id': self.pricelist_usd.id, + }) + sale_order.action_confirm() + task = self.env['project.task'].search([('sale_line_id', '=', sale_order.order_line.id)]) + + # let's log some timesheets + self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': sale_order.project_project_id.id, + 'task_id': task.id, + 'unit_amount': 16, + 'employee_id': self.employee_manager.id, + }) + self.assertEqual(sale_order.order_line.qty_delivered, 2, 'Sale: uom conversion of timesheets is wrong') + + self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': sale_order.project_project_id.id, + 'task_id': task.id, + 'unit_amount': 24, + 'employee_id': self.employee_user.id, + }) + sale_order.action_invoice_create() + self.assertEqual(sale_order.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so') diff --git a/addons/sale_timesheet/tests/test_sale_timesheet.py b/addons/sale_timesheet/tests/test_sale_timesheet.py index d75991411cb..bb8cf479a1d 100644 --- a/addons/sale_timesheet/tests/test_sale_timesheet.py +++ b/addons/sale_timesheet/tests/test_sale_timesheet.py @@ -1,140 +1,271 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo.tools import float_is_zero +from odoo.tools import float_is_zero, float_compare -from odoo.addons.sale.tests.test_sale_common import TestSale +from odoo.addons.sale_timesheet.tests.common import CommonTest -class TestSaleTimesheet(TestSale): - - def setUp(self): - super(TestSaleTimesheet, self).setUp() - # link hr employee to res.users of sale tests - self.employee_user = self.env['hr.employee'].create({ - 'name': 'Employee User', - 'user_id': self.user.id, - }) - self.employee_manager = self.env['hr.employee'].create({ - 'name': 'Employee Manager', - 'user_id': self.manager.id, - }) +class TestSaleTimesheet(CommonTest): + """ This test suite provide tests for the 3 main flows of selling services: + - Selling services based on ordered quantities + - Selling timesheet based on delivered quantities + - Selling milestones, based on manual delivered quantities + For that, we check the task/project created, the invoiced amounts, the delivered + quantities changes, ... + """ def test_timesheet_order(self): - """ Test timesheet invoicing with 'invoice on order' timetracked products """ - # intial so - prod_ts = self.env.ref('product.service_order_01') - so_vals = { - 'partner_id': self.partner.id, - 'partner_invoice_id': self.partner.id, - 'partner_shipping_id': self.partner.id, - 'order_line': [(0, 0, {'name': prod_ts.name, 'product_id': prod_ts.id, 'product_uom_qty': 50, 'product_uom': prod_ts.uom_id.id, 'price_unit': prod_ts.list_price})], - 'pricelist_id': self.env.ref('product.list0').id, - } - so = self.env['sale.order'].create(so_vals) - so.action_confirm() - so.action_invoice_create() + """ Test timesheet invoicing with 'invoice on order' timetracked products + 1. create SO with 2 ordered product and confirm + 2. create invoice + 3. log timesheet + 4. add new SO line (ordered service) + 5. create new invoice + """ + # create SO and confirm it + sale_order = self.env['sale.order'].create({ + 'partner_id': self.partner_usd.id, + 'partner_invoice_id': self.partner_usd.id, + 'partner_shipping_id': self.partner_usd.id, + 'pricelist_id': self.pricelist_usd.id, + }) + so_line_ordered_project_only = self.env['sale.order.line'].create({ + 'name': self.product_order_timesheet4.name, + 'product_id': self.product_order_timesheet4.id, + 'product_uom_qty': 10, + 'product_uom': self.product_order_timesheet4.uom_id.id, + 'price_unit': self.product_order_timesheet4.list_price, + 'order_id': sale_order.id, + }) + so_line_ordered_global_project = self.env['sale.order.line'].create({ + 'name': self.product_order_timesheet2.name, + 'product_id': self.product_order_timesheet2.id, + 'product_uom_qty': 50, + 'product_uom': self.product_order_timesheet2.uom_id.id, + 'price_unit': self.product_order_timesheet2.list_price, + 'order_id': sale_order.id, + }) + so_line_ordered_project_only.product_id_change() + so_line_ordered_global_project.product_id_change() + sale_order.action_confirm() + task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_ordered_global_project.id)]) - # let's log some timesheets + self.assertEqual(sale_order.tasks_count, 1, "One task should have been created on SO confirmation") + self.assertTrue(sale_order.project_project_id, "A project should have been created by the SO, when confirmed.") + + # create invoice + sale_order.action_invoice_create() + + # let's log some timesheets (on the project created by so_line_ordered_project_only) self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': so.project_project_id.id, + 'project_id': sale_order.project_project_id.id, + 'task_id': task_serv2.id, 'unit_amount': 10.5, 'employee_id': self.employee_user.id, }) - self.assertEqual(so.order_line.qty_delivered, 10.5, 'Sale Timesheet: timesheet does not increase delivered quantity on so line') - self.assertEqual(so.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so') + self.assertEqual(so_line_ordered_global_project.qty_delivered, 10.5, 'Timesheet directly on project does not increase delivered quantity on so line') + self.assertEqual(sale_order.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so') self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': so.project_project_id.id, + 'project_id': sale_order.project_project_id.id, + 'task_id': task_serv2.id, 'unit_amount': 39.5, 'employee_id': self.employee_user.id, }) - self.assertEqual(so.order_line.qty_delivered, 50, 'Sale Timesheet: timesheet does not increase delivered quantity on so line') - self.assertEqual(so.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so') + self.assertEqual(so_line_ordered_global_project.qty_delivered, 50, 'Sale Timesheet: timesheet does not increase delivered quantity on so line') + self.assertEqual(sale_order.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so') self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': so.project_project_id.id, + 'project_id': sale_order.project_project_id.id, 'unit_amount': 10, 'employee_id': self.employee_user.id, }) - self.assertEqual(so.order_line.qty_delivered, 60, 'Sale Timesheet: timesheet does not increase delivered quantity on so line') - self.assertEqual(so.invoice_status, 'upselling', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so') + self.assertEqual(so_line_ordered_project_only.qty_delivered, 0.0, 'Timesheet directly on project does not increase delivered quantity on so line') + + # log timesheet on task in global project (higher than the initial ordrered qty) + self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': sale_order.project_project_id.id, + 'task_id': task_serv2.id, + 'unit_amount': 5, + 'employee_id': self.employee_user.id, + }) + self.assertEqual(sale_order.invoice_status, 'upselling', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so') + + # add so line with produdct "create task in new project". (project will be the one from SO) + so_line_ordered_task_new_project = self.env['sale.order.line'].create({ + 'name': self.product_order_timesheet3.name, + 'product_id': self.product_order_timesheet3.id, + 'product_uom_qty': 3, + 'product_uom': self.product_order_timesheet3.uom_id.id, + 'price_unit': self.product_order_timesheet3.list_price, + 'order_id': sale_order.id, + }) + task_serv3 = self.env['project.task'].search([('sale_line_id', '=', so_line_ordered_task_new_project.id)]) + self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Timesheet: Adding a new service line (so line) should put the SO in "to invocie" state.') + self.assertEqual(sale_order.tasks_count, 2, "Two tasks (1 per SO line) should have been created on SO confirmation") + + # create invoice + invoice_id = sale_order.action_invoice_create() + invoice = self.env['account.invoice'].browse(invoice_id) + self.assertEqual(len(sale_order.invoice_ids), 2, "A second invoice should have been created from the SO") + self.assertTrue(float_is_zero(invoice.amount_total - so_line_ordered_task_new_project.price_unit * 3, precision_digits=2), 'Sale: invoice generation on timesheets product is wrong') + self.assertEqual(sale_order.project_project_id, task_serv3.project_id, "When creating task in new project, the task should be in SO project (if already exists), otherwise it created one.") def test_timesheet_delivery(self): - """ Test timesheet invoicing with 'invoice on delivery' timetracked products """ - inv_obj = self.env['account.invoice'] - # intial so - prod_ts = self.env.ref('product.product_product_2') - so_vals = { - 'partner_id': self.partner.id, - 'partner_invoice_id': self.partner.id, - 'partner_shipping_id': self.partner.id, - 'order_line': [(0, 0, {'name': prod_ts.name, 'product_id': prod_ts.id, 'product_uom_qty': 50, 'product_uom': prod_ts.uom_id.id, 'price_unit': prod_ts.list_price})], - 'pricelist_id': self.env.ref('product.list0').id, - } - so = self.env['sale.order'].create(so_vals) - so.action_confirm() - self.assertEqual(so.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" should not need to be invoiced on so confirmation') + """ Test timesheet invoicing with 'invoice on delivery' timetracked products + 1. Create SO and confirm it + 2. log timesheet + 3. create invoice + 4. log other timesheet + 5. create a second invoice + 6. add new SO line (delivered service) + """ + # create SO and confirm it + sale_order = self.env['sale.order'].create({ + 'partner_id': self.partner_usd.id, + 'partner_invoice_id': self.partner_usd.id, + 'partner_shipping_id': self.partner_usd.id, + 'pricelist_id': self.pricelist_usd.id, + }) + 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': sale_order.id, + }) + so_line_deliver_task_project = self.env['sale.order.line'].create({ + 'name': self.product_delivery_timesheet3.name, + 'product_id': self.product_delivery_timesheet3.id, + 'product_uom_qty': 20, + 'product_uom': self.product_delivery_timesheet3.uom_id.id, + 'price_unit': self.product_delivery_timesheet3.list_price, + 'order_id': sale_order.id, + }) + so_line_deliver_global_project.product_id_change() + so_line_deliver_task_project.product_id_change() + + # confirm SO + sale_order.action_confirm() + task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)]) + task_serv3 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_task_project.id)]) + + self.assertEqual(task_serv2.project_id, self.project_global, "Sale Timesheet: task should be created in global project") + self.assertTrue(task_serv2, "Sale Timesheet: on SO confirmation, a task should have been created in global project") + self.assertTrue(task_serv3, "Sale Timesheet: on SO confirmation, a task should have been created in a new project") + self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" should not need to be invoiced on so confirmation') + self.assertEqual(sale_order.analytic_account_id, task_serv3.project_id.analytic_account_id, "SO should have create a project") + self.assertEqual(sale_order.tasks_count, 2, "Two tasks (1 per SO line) should have been created on SO confirmation") + # let's log some timesheets self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': so.project_project_id.id, + 'project_id': task_serv2.project_id.id, # global project + 'task_id': task_serv2.id, 'unit_amount': 10.5, 'employee_id': self.employee_manager.id, }) - self.assertEqual(so.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should set the so in "to invoice" status when logged') - inv_id = so.action_invoice_create() - inv = inv_obj.browse(inv_id) - self.assertTrue(float_is_zero(inv.amount_total - so.order_line.price_unit * 10.5, precision_digits=2), 'Sale: invoice generation on timesheets product is wrong') + self.assertEqual(so_line_deliver_global_project.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should set the so line in "to invoice" status when logged') + self.assertEqual(so_line_deliver_task_project.invoice_status, 'no', 'Sale Timesheet: so line invoice status should not change when no timesheet linked to the line') + self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should set the so in "to invoice" status when logged') + # invoice SO + invoice_id = sale_order.action_invoice_create() + invoice = self.env['account.invoice'].browse(invoice_id) + self.assertTrue(float_is_zero(invoice.amount_total - so_line_deliver_global_project.price_unit * 10.5, precision_digits=2), 'Sale: invoice generation on timesheets product is wrong') + + # log some timesheets again self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': so.project_project_id.id, + 'project_id': task_serv2.project_id.id, # global project + 'task_id': task_serv2.id, 'unit_amount': 39.5, 'employee_id': self.employee_user.id, }) - self.assertEqual(so.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so') - so.action_invoice_create() - self.assertEqual(so.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on delivery" timesheets should be invoiced completely by now') + self.assertEqual(so_line_deliver_global_project.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should set the so line in "to invoice" status when logged') + self.assertEqual(so_line_deliver_task_project.invoice_status, 'no', 'Sale Timesheet: so line invoice status should not change when no timesheet linked to the line') + self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so') + # create a second invoice + sale_order.action_invoice_create() + self.assertEqual(so_line_deliver_global_project.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on delivery" timesheets should set the so line in "to invoice" status when logged') + self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" timesheets should be invoiced completely by now') + + # add a line on SO + so_line_deliver_only_project = self.env['sale.order.line'].create({ + 'name': self.product_delivery_timesheet4.name, + 'product_id': self.product_delivery_timesheet4.id, + 'product_uom_qty': 5, + 'product_uom': self.product_delivery_timesheet4.uom_id.id, + 'price_unit': self.product_delivery_timesheet4.list_price, + 'order_id': sale_order.id, + }) + self.assertEqual(sale_order.project_project_id, task_serv3.project_id, "SO should not have create a second project, since so line 3 already create one project for the SO") + + # let's log some timesheets on the project self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': so.project_project_id.id, - 'unit_amount': 10, + 'project_id': sale_order.project_project_id.id, # global project + 'unit_amount': 7, 'employee_id': self.employee_user.id, }) - self.assertEqual(so.invoice_status, 'to invoice', 'Sale Timesheet: supplementary timesheets do not change the status of the SO') + # import pdb;pdb.set_trace() + 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, 'no', 'Sale Timesheet: "invoice on delivery" timesheets should be invoiced completely by now') - def test_timesheet_uom(self): - """ Test timesheet invoicing and uom conversion """ - # intial so - prod_ts = self.env.ref('product.product_product_2') - uom_days = self.env.ref('product.product_uom_day') - so_vals = { - 'partner_id': self.partner.id, - 'partner_invoice_id': self.partner.id, - 'partner_shipping_id': self.partner.id, - 'order_line': [(0, 0, {'name': prod_ts.name, 'product_id': prod_ts.id, 'product_uom_qty': 5, 'product_uom': uom_days.id, 'price_unit': prod_ts.list_price})], - 'pricelist_id': self.env.ref('product.list0').id, - } - so = self.env['sale.order'].create(so_vals) - so.action_confirm() - # let's log some timesheets + def test_timesheet_manual(self): + """ Test timesheet invoicing with 'invoice on delivery' timetracked products + """ + # create SO and confirm it + sale_order = self.env['sale.order'].create({ + 'partner_id': self.partner_usd.id, + 'partner_invoice_id': self.partner_usd.id, + 'partner_shipping_id': self.partner_usd.id, + 'pricelist_id': self.pricelist_usd.id, + }) + so_line_manual_global_project = self.env['sale.order.line'].create({ + 'name': self.product_delivery_manual2.name, + 'product_id': self.product_delivery_manual2.id, + 'product_uom_qty': 50, + 'product_uom': self.product_delivery_manual2.uom_id.id, + 'price_unit': self.product_delivery_manual2.list_price, + 'order_id': sale_order.id, + }) + so_line_manual_only_project = self.env['sale.order.line'].create({ + 'name': self.product_delivery_manual4.name, + 'product_id': self.product_delivery_manual4.id, + 'product_uom_qty': 20, + 'product_uom': self.product_delivery_manual4.uom_id.id, + 'price_unit': self.product_delivery_manual4.list_price, + 'order_id': sale_order.id, + }) + + # confirm SO + sale_order.action_confirm() + self.assertTrue(sale_order.project_project_id, "Sales Order should have create a project") + self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: manually product should not need to be invoiced on so confirmation') + + # let's log some timesheets (on task and project) self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': so.project_project_id.id, - 'unit_amount': 16, + 'project_id': self.project_global.id, # global project + 'task_id': so_line_manual_global_project.task_id.id, + 'unit_amount': 6, 'employee_id': self.employee_manager.id, }) - self.assertEqual(so.order_line.qty_delivered, 2, 'Sale: uom conversion of timesheets is wrong') self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': so.project_project_id.id, - 'unit_amount': 24, - 'employee_id': self.employee_user.id, + 'project_id': sale_order.project_project_id.id, # global project + 'unit_amount': 3, + 'employee_id': self.employee_manager.id, }) - so.action_invoice_create() - self.assertEqual(so.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so') + + self.assertEqual(so_line_manual_global_project.qty_to_invoice, 0.0, "Manual service should not be affected by timesheet on their created task.") + self.assertEqual(so_line_manual_only_project.qty_to_invoice, 0.0, "Manual service should not be affected by timesheet on their created project.") + self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" should not need to be invoiced on so confirmation') diff --git a/addons/sale_timesheet/tests/test_timesheet_revenue.py b/addons/sale_timesheet/tests/test_timesheet_revenue.py index 4e72ba3c9a2..79c14e68c16 100644 --- a/addons/sale_timesheet/tests/test_timesheet_revenue.py +++ b/addons/sale_timesheet/tests/test_timesheet_revenue.py @@ -35,7 +35,7 @@ class TestSaleTimesheet(TestSale): 'default_code': 'SERV-DELI', 'service_type': 'timesheet', 'service_tracking': 'task_global_project', - 'project_id': self.project.id + 'project_id': self.project.id, }) self.product_order = self.env['product.product'].create({ @@ -49,7 +49,7 @@ class TestSaleTimesheet(TestSale): 'default_code': 'SERV-ORDER', 'service_type': 'timesheet', 'service_tracking': 'task_global_project', - 'project_id': self.project.id + 'project_id': self.project.id, }) # pricelists @@ -65,7 +65,6 @@ class TestSaleTimesheet(TestSale): 'currency_id': self.env.ref('base.EUR').id, 'company_id': self.env.user.company_id.id, }) - # partners self.partner_usd = self.env['res.partner'].create({ 'name': 'Cool Partner in USD', From 0e648b9e540d2c7e057342a8249d34256da908b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Maes?= Date: Fri, 14 Jul 2017 13:49:36 +0200 Subject: [PATCH 09/20] [REF] hr_timesheet,sale_timesheet: cleaning create/write overrides --- addons/hr_timesheet/models/hr_timesheet.py | 29 +-- addons/sale_timesheet/models/account.py | 183 ++++++++---------- .../sale_timesheet/models/account_invoice.py | 2 +- 3 files changed, 103 insertions(+), 111 deletions(-) diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py index 3023fdcea59..68a64a7fbde 100644 --- a/addons/hr_timesheet/models/hr_timesheet.py +++ b/addons/hr_timesheet/models/hr_timesheet.py @@ -30,25 +30,32 @@ class AccountAnalyticLine(models.Model): @api.model def create(self, vals): - vals = self._update_timesheet_values(vals) - if not vals.get('employee_id') and vals.get('project_id'): # compute employee only for timesheet lines, makes no sense for other lines - if vals.get('user_id'): - ts_user_id = vals['user_id'] - else: - ts_user_id = self.env.user.id - vals['employee_id'] = self.env['hr.employee'].search([('user_id', '=', ts_user_id)], limit=1).id + vals = self._timesheet_preprocess(vals) return super(AccountAnalyticLine, self).create(vals) @api.multi def write(self, vals): - vals = self._update_timesheet_values(vals) + vals = self._timesheet_preprocess(vals) return super(AccountAnalyticLine, self).write(vals) - def _update_timesheet_values(self, vals): - if vals.get('project_id'): + def _timesheet_preprocess(self, vals): + """ Deduce other field values from the one given. + Overrride this to compute on the fly some field that can not be computed fields. + :param values: dict values for `create`or `write`. + """ + # project implies analytic account + if vals.get('project_id') and not vals.get('account_id'): project = self.env['project.project'].browse(vals.get('project_id')) vals['account_id'] = project.analytic_account_id.id - if vals.get('employee_id'): + # employee implies user + if vals.get('employee_id') and not vals.get('user_id'): employee = self.env['hr.employee'].browse(vals['employee_id']) vals['user_id'] = employee.user_id.id + # compute employee only for timesheet lines, makes no sense for other lines + if not vals.get('employee_id') and vals.get('project_id'): + if vals.get('user_id'): + ts_user_id = vals['user_id'] + else: + ts_user_id = self._default_user() + vals['employee_id'] = self.env['hr.employee'].search([('user_id', '=', ts_user_id)], limit=1).id return vals diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py index d6da4910a36..96f4aa23bd9 100644 --- a/addons/sale_timesheet/models/account.py +++ b/addons/sale_timesheet/models/account.py @@ -17,67 +17,55 @@ class AccountAnalyticLine(models.Model): @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_values(values)) - values.update(self._get_timesheet_billing_values(values)) - return super(AccountAnalyticLine, self).create(values) + result = super(AccountAnalyticLine, self).create(values) + # applied only for timesheet + if result.project_id: + result._timesheet_postprocess(values) + return result @api.multi def write(self, values): # prevent to update invoiced timesheets if one line is of type delivery if self.sudo().filtered(lambda aal: aal.so_line.product_id.invoice_policy == "delivery") and 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.')) - - 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_values(values)) - values.update(line._get_timesheet_billing_values(values)) - super(AccountAnalyticLine, line).write(values) - - return True - - 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'): - return {} - unit_amount = values.get('unit_amount', 0.0) or self.unit_amount - - # find the employee - employee_id = values.get('employee_id') or self.employee_id.id or (self.env.user.employee_ids and self.env.user.employee_ids[0].id or False) - employee = self.env['hr.employee'].browse(employee_id) if employee_id else self.env['hr.employee'] - if not employee: - user_id = values.get('user_id') or self.user_id.id or self._default_user() - employee = self.env['hr.employee'].search([('user_id', '=', user_id)], limit=1) - - # find the analytic account - account_id = values.get('account_id') or self.account_id.id - if not account_id and values.get('project_id'): - project = self.env['project.project'].browse(values['project_id']) - account_id = project.analytic_account_id.id - analytic_account = self.env['account.analytic.account'].browse(account_id) if account_id else self.env['account.analytic.account'] - - # convert employee cost into timesheet (analytic account) currency - uom = employee.company_id.project_time_mode_id - cost = employee.timesheet_cost or 0.0 - amount = -unit_amount * cost - amount_converted = employee.currency_id.compute(amount, analytic_account.currency_id) - - result.update({ - 'amount': amount_converted, - 'product_uom_id': uom.id, - 'account_id': account_id, - }) + raise UserError(_('You can not modify already invoiced timesheets (linked to a Sales order items invoiced on Time and material).')) + result = super(AccountAnalyticLine, self).write(values) + # applied only for timesheet + self.filtered(lambda t: t.project_id)._timesheet_postprocess(values) return result - def _get_timesheet_billing_values(self, values): - """ + @api.model + def _timesheet_preprocess(self, values): + values = super(AccountAnalyticLine, self)._timesheet_preprocess(values) + # task implies so line + if 'task_id' in values: + task = self.env['project.task'].sudo().browse(values['task_id']) + values['so_line'] = task.sale_line_id.id or values.get('so_line', False) + return values + + @api.multi + def _timesheet_postprocess(self, values): + sudo_self = self.sudo() # this creates only one env for all operation that required sudo() + # (re)compute the amount (depending on unit_amount, employee_id for the cost, and account_id for currency) + if any([field_name in values for field_name in ['unit_amount', 'employee_id', 'account_id']]): + for timesheet in sudo_self: + uom = timesheet.employee_id.company_id.project_time_mode_id + cost = timesheet.employee_id.timesheet_cost or 0.0 + amount = -timesheet.unit_amount * cost + amount_converted = timesheet.employee_id.currency_id.compute(amount, timesheet.account_id.currency_id) + timesheet.write({ + 'amount': amount_converted, + 'product_uom_id': uom.id, + }) + # (re)compute the theorical revenue + if any([field_name in values for field_name in ['so_line', 'unit_amount', 'account_id']]): + sudo_self._timesheet_compute_theorical_revenue() + return values + + @api.multi + def _timesheet_compute_theorical_revenue(self): + """ This method set the theorical revenue on the current timesheet lines. + If invoice on delivered quantity: timesheet hours * (SO Line Price) * (1- discount), elif invoice on ordered quantities & create task: @@ -88,53 +76,50 @@ class AccountAnalyticLine(models.Model): else: 0 """ - result = {} - if self.env.context.get('create'): # avoid bad loop - return result + for timesheet in self: + # find the timesheet UoM + timesheet_uom = timesheet.product_uom_id + if not timesheet_uom: # fallback on default company timesheet UoM + timesheet_uom = self.env.user.company_id.project_time_mode_id - # find the timesheet UoM - timesheet_uom = self.product_uom_id - if values.get('product_uom_id'): - timesheet_uom = self.env['product.uom'].browse(values['product_uom_id']) - if not timesheet_uom: # fallback on default company timesheet UoM - timesheet_uom = self.env.user.company_id.project_time_mode_id + # default values + billable_type = 'non_billable' + unit_amount = timesheet.unit_amount + so_line = timesheet.so_line + revenue = 0.0 + billable_type = 'non_billable' - 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 + if so_line.product_id.type == 'service' and so_line.product_id.service_type == 'timesheet': + # find the analytic account to convert revenue into its currency + analytic_account = timesheet.account_id + # convert the unit of mesure into hours + sale_price_hour = so_line.product_uom._compute_price(so_line.price_unit, timesheet_uom) + sale_price = so_line.currency_id.compute(sale_price_hour, analytic_account.currency_id) # amount from SO should be convert into analytic account currency - # 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).sudo() if so_line_id else self.env['sale.order.line'].browse().sudo() - 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) - # convert the unit of mesure into hours - sale_price_hour = so_line.product_uom._compute_price(so_line.price_unit, timesheet_uom) - sale_price = so_line.currency_id.compute(sale_price_hour, analytic_account.currency_id) # amount from SO should be convert into analytic account currency - # calculate the revenue on the timesheet - if so_line.product_id.invoice_policy == 'delivery': - revenue = analytic_account.currency_id.round(unit_amount * sale_price * (1-(so_line.discount/100))) - billable_type = 'billable_time' - elif so_line.product_id.invoice_policy == 'order': - quantity_hour = unit_amount - if so_line.product_uom.category_id == timesheet_uom.category_id: + # calculate the revenue on the timesheet + if so_line.product_id.invoice_policy == 'delivery': + revenue = analytic_account.currency_id.round(unit_amount * sale_price * (1-(so_line.discount/100))) + billable_type = 'billable_time' + elif so_line.product_id.invoice_policy == 'order': quantity_hour = so_line.product_uom._compute_quantity(so_line.product_uom_qty, timesheet_uom) - # compute the total revenue the SO since we are in fixed price - total_revenue_so = analytic_account.currency_id.round(quantity_hour * sale_price * (1-(so_line.discount/100))) - # 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( - analytic_account.currency_id.round(unit_amount * so_line.currency_id.compute(so_line.price_unit, analytic_account.currency_id) * (1-(so_line.discount/100))), - total_revenue_so - total_revenue_invoiced - ) - billable_type = 'billable_fixed' - result['timesheet_revenue'] = revenue - result['timesheet_invoice_type'] = billable_type - return result + # compute the total revenue the SO since we are in fixed price + total_revenue_so = analytic_account.currency_id.round(quantity_hour * sale_price * (1-(so_line.discount/100))) + # compute the total revenue already existing (without the current timesheet line) + domain = [('so_line', '=', so_line.id)] + if timesheet.ids: + domain += [('id', 'not in', timesheet.ids)] + analytic_lines = timesheet.search(domain) + total_revenue_invoiced = sum(analytic_lines.mapped('timesheet_revenue')) + # compute (new) revenue of current timesheet line + revenue = min( + analytic_account.currency_id.round(unit_amount * so_line.currency_id.compute(so_line.price_unit, analytic_account.currency_id) * (1-so_line.discount)), + total_revenue_so - total_revenue_invoiced + ) + billable_type = 'billable_fixed' + + timesheet.write({ + 'timesheet_revenue': revenue, + 'timesheet_invoice_type': billable_type, + }) + return True diff --git a/addons/sale_timesheet/models/account_invoice.py b/addons/sale_timesheet/models/account_invoice.py index 27dcd59243c..c4eb3d46c38 100644 --- a/addons/sale_timesheet/models/account_invoice.py +++ b/addons/sale_timesheet/models/account_invoice.py @@ -59,7 +59,7 @@ class AccountInvoice(models.Model): ('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: From 7c1a1d15eebc50420e376138f45064059242640e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Maes?= Date: Fri, 14 Jul 2017 16:14:46 +0200 Subject: [PATCH 10/20] [IMP] sale_timesheet: assign task to so line When assigning task to a SO line (through procurement), related timesheet should become billable (according to the product of so line), and have their revenue recomputed. This commits also add a test. --- addons/hr_timesheet/models/project.py | 2 +- addons/hr_timesheet/views/project_views.xml | 15 +++-- addons/sale_timesheet/models/project.py | 12 +++- addons/sale_timesheet/models/sale_order.py | 2 +- .../sale_timesheet/tests/test_sale_service.py | 57 +++++++++++++++++++ .../views/project_task_views.xml | 4 +- 6 files changed, 81 insertions(+), 11 deletions(-) diff --git a/addons/hr_timesheet/models/project.py b/addons/hr_timesheet/models/project.py index 0fc7aa314bf..8a559139e59 100644 --- a/addons/hr_timesheet/models/project.py +++ b/addons/hr_timesheet/models/project.py @@ -42,7 +42,7 @@ class Task(models.Model): effective_hours = fields.Float(compute='_hours_get', store=True, string='Hours Spent', help="Computed using the sum of the task work done.") total_hours = fields.Float(compute='_hours_get', store=True, string='Total', help="Computed as: Time Spent + Remaining Time.") total_hours_spent = fields.Float(compute='_hours_get', store=True, string='Total Hours', help="Computed as: Time Spent + Sub-tasks Hours.") - progress = fields.Float(compute='_hours_get', store=True, string='Working Time Recorded', group_operator="avg") + progress = fields.Float(compute='_hours_get', store=True, string='Progress', group_operator="avg") delay_hours = fields.Float(compute='_hours_get', store=True, string='Delay Hours', help="Computed as difference between planned hours by the project manager and the total hours of the task.") children_hours = fields.Float(compute='_hours_get', store=True, string='Sub-tasks Hours', help="Sum of the planned hours of all sub-tasks (when a sub-task is closed or its spent hours exceed its planned hours, spent hours are counted instead)") timesheet_ids = fields.One2many('account.analytic.line', 'task_id', 'Timesheets') diff --git a/addons/hr_timesheet/views/project_views.xml b/addons/hr_timesheet/views/project_views.xml index b7cbda8ac4c..41c64d9b3f6 100644 --- a/addons/hr_timesheet/views/project_views.xml +++ b/addons/hr_timesheet/views/project_views.xml @@ -57,14 +57,17 @@ - - - - - - + + + + + + + + + diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 8adeec58b06..1bbbb6bc1e8 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -56,7 +56,17 @@ class Project(models.Model): class ProjectTask(models.Model): _inherit = "project.task" - sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Line') + sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', domain=[('is_service', '=', True)]) + + @api.multi + def write(self, values): + result = super(ProjectTask, self).write(values) + # reassign SO line on related timesheet lines + if 'sale_line_id' in values: + self.sudo().mapped('timesheet_ids').write({ + 'so_line': values['sale_line_id'] + }) + return result @api.multi def unlink(self): diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index d063126a67f..5b0dcc3461f 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -124,7 +124,7 @@ class SaleOrderLine(models.Model): _inherit = "sale.order.line" task_id = fields.Many2one('project.task', 'Task', help="Task generated by the sales order item") - is_service = fields.Boolean("Is service", compute='_compute_is_service', help="Sales Order item should generate a task and/or a project, depending on the product settings.") + is_service = fields.Boolean("Is service", compute='_compute_is_service', store=True, help="Sales Order item should generate a task and/or a project, depending on the product settings.") @api.multi @api.depends('product_id.type') diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py index 5e92fe92fa2..388a3830122 100644 --- a/addons/sale_timesheet/tests/test_sale_service.py +++ b/addons/sale_timesheet/tests/test_sale_service.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo.addons.sale_timesheet.tests.common import CommonTest +from odoo.exceptions import UserError class TestSaleService(CommonTest): @@ -114,3 +115,59 @@ class TestSaleService(CommonTest): }) sale_order.action_invoice_create() self.assertEqual(sale_order.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so') + + def test_task_so_line_assignation(self): + # create SO and confirm it + sale_order = self.env['sale.order'].create({ + 'partner_id': self.partner_usd.id, + 'partner_invoice_id': self.partner_usd.id, + 'partner_shipping_id': self.partner_usd.id, + 'pricelist_id': self.pricelist_usd.id, + }) + 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': 10, + 'product_uom': self.product_delivery_timesheet2.uom_id.id, + 'price_unit': self.product_delivery_timesheet2.list_price, + 'order_id': sale_order.id, + }) + so_line_deliver_global_project.product_id_change() + sale_order.action_confirm() + task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)]) + + # let's log some timesheets (on the project created by so_line_ordered_project_only) + timesheets = self.env['account.analytic.line'] + timesheets |= self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': task_serv2.project_id.id, + 'task_id': task_serv2.id, + 'unit_amount': 4, + 'employee_id': self.employee_user.id, + }) + timesheets |= self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'project_id': task_serv2.project_id.id, + 'task_id': task_serv2.id, + 'unit_amount': 1, + 'employee_id': self.employee_manager.id, + }) + self.assertTrue(all([billing_type == 'billable_time' for billing_type in timesheets.mapped('timesheet_invoice_type')]), "All timesheets linked to the task should be on 'billable time'") + self.assertEqual(so_line_deliver_global_project.qty_to_invoice, 5, "Quantity to invoice should have been increased when logging timesheet on delivered quantities task") + + # make task non billable + task_serv2.write({'sale_line_id': False}) + self.assertTrue(all([billing_type == 'non_billable' for billing_type in timesheets.mapped('timesheet_invoice_type')]), "Timesheet to a non billable task should be non billable too") + + # make task billable again + task_serv2.write({'sale_line_id': so_line_deliver_global_project.id}) + self.assertTrue(all([billing_type == 'billable_time' for billing_type in timesheets.mapped('timesheet_invoice_type')]), "Timesheet to a billable time task should be billable") + + # invoice SO, and validate invoice + invoice_id = sale_order.action_invoice_create()[0] + invoice = self.env['account.invoice'].browse(invoice_id) + invoice.action_invoice_open() + + # try to update timesheets, catch error 'You cannot modify invoiced timesheet' + with self.assertRaises(UserError): + task_serv2.write({'sale_line_id': False}) diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index 27f26ad60a6..bd679bb68cd 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -26,8 +26,8 @@ attrs="{'invisible': [('sale_line_id', '=', False)]}" string="Sales Order"/> - - + + From 063987c989162c68f1d78afab5e9dbb3a854aadc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Maes?= Date: Mon, 19 Jun 2017 09:43:11 +0200 Subject: [PATCH 11/20] [IMP] project,hr_timesheet,project_issue: clickable zone of project kanban box When clicking on kanban box, the first action is trigger; the user have to click on the label of the box to see the 'timesheet' (for instance), otherwise he will see the tasks. Since HTML5 supports `div`, `table`, ... being wrapped by `a` tag, we can use it to make the entire kanban box clickable. (See https://www.w3.org/TR/html5/text-level-semantics.html#the-a-element) --- addons/hr_timesheet/views/project_views.xml | 10 +++++----- addons/project/static/src/js/tour.js | 2 +- addons/project/views/project_views.xml | 14 +++++++------- addons/sale_timesheet/views/project_task_views.xml | 6 ++++-- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/addons/hr_timesheet/views/project_views.xml b/addons/hr_timesheet/views/project_views.xml index 41c64d9b3f6..4b3bf9ad4b0 100644 --- a/addons/hr_timesheet/views/project_views.xml +++ b/addons/hr_timesheet/views/project_views.xml @@ -123,12 +123,12 @@ Timesheets - - + diff --git a/addons/project/static/src/js/tour.js b/addons/project/static/src/js/tour.js index 3e844c309b0..24a3e7f0c19 100644 --- a/addons/project/static/src/js/tour.js +++ b/addons/project/static/src/js/tour.js @@ -31,7 +31,7 @@ tour.register('project_tour', { content: _t('Click on the card to go to your project and start organizing tasks.'), position: 'right', run: function (actions) { - actions.auto(this.$anchor.find(".o_project_kanban_box:first > a")); + actions.auto(this.$anchor.find(".o_project_kanban_box:first")); }, }, { trigger: ".o_kanban_project_tasks .o_column_quick_create", diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index 4ecc31ae062..b714cd28c9d 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -327,15 +327,15 @@ diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index bd679bb68cd..a311c8b9a1c 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -7,8 +7,10 @@ - - Overview + +
+ Overview +
From 26905ec24aeb2e379b29cd7701603699f7ecc158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Maes?= Date: Wed, 2 Aug 2017 13:51:32 +0200 Subject: [PATCH 12/20] [IMP] sale_timesheet: name of generated project/task on SO confirmation The project name should take the customer ref into account, when creating project. The task name should be the first line of the SO description to avoid having multiple tasks generated with the same name. --- addons/sale_timesheet/models/sale_order.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index 5b0dcc3461f..d84628af9d8 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -179,8 +179,9 @@ class SaleOrderLine(models.Model): account = self.order_id.analytic_account_id project = Project.search([('analytic_account_id', '=', account.id)], limit=1) if not project: + project_name = '%s (%s)' % (account.name, self.sale_line_id.order_partner_id.ref) if self.sale_line_id.order_partner_id.ref else account.name project_id = account.sudo().project_create({ - 'name': account.name, + 'name': project_name, 'allow_timesheets': self.product_id.service_type == 'timesheet', }) project = Project.sudo().browse(project_id) @@ -194,7 +195,7 @@ class SaleOrderLine(models.Model): project = self._timesheet_find_project() planned_hours = self._convert_qty_company_hours() return { - 'name': '%s:%s' % (self.order_id.name or '', self.product_id.name), + 'name': '%s:%s' % (self.order_id.name or '', self.name.split('\n')[0] or self.product_id.name), 'planned_hours': planned_hours, 'remaining_hours': planned_hours, 'partner_id': self.order_id.partner_id.id, From 40beeed12d578f08cf87161db1ebe7a825178400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Maes?= Date: Mon, 17 Jul 2017 12:00:34 +0200 Subject: [PATCH 13/20] [IMP] sale_timesheet: projects of sale order The stat button now shows all the project involved in the sale order (created, or linked). This commits introduce the field `project_ids`, containing all the project involved in the sales order (created, or linked via products). The stat button now shows all involded projects (if many) or the timesheet overview of the single project. --- addons/sale_timesheet/models/sale_order.py | 32 +++++++++++++------ .../sale_timesheet/views/sale_order_views.xml | 10 +++--- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index d84628af9d8..cddb6f6abd9 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -5,18 +5,20 @@ from odoo import api, fields, models, _ from odoo.exceptions import ValidationError from odoo.osv import expression +from odoo.tools.safe_eval import safe_eval class SaleOrder(models.Model): _inherit = 'sale.order' timesheet_ids = fields.Many2many('account.analytic.line', compute='_compute_timesheet_ids', string='Timesheet activities associated to this sale') - timesheet_count = fields.Float(string='Timesheet activities', compute='_compute_timesheet_ids') + timesheet_count = fields.Float(string='Timesheet activities', compute='_compute_timesheet_ids', groups="hr_timesheet.group_hr_timesheet_user") tasks_ids = fields.Many2many('project.task', compute='_compute_tasks_ids', string='Tasks associated to this sale') - tasks_count = fields.Integer(string='Tasks', compute='_compute_tasks_ids') + tasks_count = fields.Integer(string='Tasks', compute='_compute_tasks_ids', groups="project.group_project_user") project_project_id = fields.Many2one('project.project', compute='_compute_project_project_id', string='Project associated to this sale') + project_ids = fields.Many2many('project.project', compute="_compute_project_ids", string='Projects', copy=False, groups="project.group_project_user", help="Projects used in this sales order.") @api.multi @api.depends('analytic_account_id.line_ids') @@ -86,15 +88,25 @@ class SaleOrder(models.Model): return result @api.multi - def action_view_project_project(self): + def action_view_project_ids(self): self.ensure_one() - action = self.env.ref('project.open_view_project_all').read()[0] - form_view_id = self.env.ref('project.edit_project').id - - action['views'] = [(form_view_id, 'form')] - action['res_id'] = self.project_project_id.id - action.pop('target', None) - + if len(self.project_ids) == 1: + if self.env.user.has_group("hr_timesheet.group_hr_timesheet_user"): + action = self.project_ids.action_view_timesheet_plan() + else: + action = self.env.ref("project.act_project_project_2_project_task_all").read()[0] + action['context'] = safe_eval(action.get('context', '{}'), {'active_id': self.project_ids.id, 'active_ids': self.project_ids.ids}) + else: + view_form_id = self.env.ref('project.edit_project').id + view_kanban_id = self.env.ref('project.view_project_kanban').id + action = { + 'type': 'ir.actions.act_window', + 'domain': [('id', 'in', self.project_ids.ids)], + 'views': [(view_kanban_id, 'kanban'), (view_form_id, 'form')], + 'view_mode': 'kanban,form', + 'name': _('Projects'), + 'res_model': 'project.project', + } return action @api.multi diff --git a/addons/sale_timesheet/views/sale_order_views.xml b/addons/sale_timesheet/views/sale_order_views.xml index 8d86ee57894..ccb4ffd66c3 100644 --- a/addons/sale_timesheet/views/sale_order_views.xml +++ b/addons/sale_timesheet/views/sale_order_views.xml @@ -16,17 +16,17 @@ - -