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] [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', }, {