From cd885ad2cdf9e4af64d2d0ae1672536bb51df09e Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Mon, 21 Dec 2020 12:17:13 +0000 Subject: [PATCH] [IMP] sale{_timesheet}: order upsell activity for prepaid services When offering services, it is critical to make sure that the time being delivered is billable. Otherwise, the company is loosing money. This commit adds two fields in product.template: 1. service_upsell_warning 2. service_upsell_threshold The first one is a boolean, if it is checked than the second one is displayed to add a threshold. This threshold is used to create a upsell activity want the (qty_delivered / qty_ordered) in a SOL of this product is greater than the threshold defined in the product. Moreover, when the following condition for a SOL: (delivered quantity / ordered quantity) >= threshold set in the product is True, then we display an upsell warning for the corresponding SO. The problem is we don't check if the warning has already displayed by a certain SOL. Thus, each time we timesheet for this SOL, we display one more time. This commit avoids to spam the salesman, to do this, we add a new boolean field in sale.order.line, this field will be True if the warning upsell activity is shown thanks to the SOL. A method is added in sale module to create the upsell activity for each SO. This method is used in sale_timesheet module to avoid duplicated code. Finally, an unit test has been added to check the feature. task-2411291 closes odoo/odoo#64252 Related: odoo/upgrade#2066 Signed-off-by: Yannick Tivisse (yti) --- addons/sale/models/sale.py | 24 ++--- addons/sale_timesheet/models/product.py | 2 + addons/sale_timesheet/models/sale_order.py | 42 +++++++++ addons/sale_timesheet/tests/__init__.py | 1 + .../tests/test_upsell_warning.py | 89 +++++++++++++++++++ addons/sale_timesheet/views/product_views.xml | 2 + 6 files changed, 150 insertions(+), 10 deletions(-) create mode 100644 addons/sale_timesheet/tests/test_upsell_warning.py diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 90c57b40060..81ec265ef4e 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -505,18 +505,11 @@ class SaleOrder(models.Model): if field.name != 'invoice_status' or self.env.context.get('mail_activity_automation_skip'): return - filtered_self = self.filtered(lambda so: so.user_id and so.invoice_status == 'upselling') - if not filtered_self: + upselling_orders = self.filtered(lambda so: (so.user_id or so.partner_id.user_id) and so.invoice_status == 'upselling') + if not upselling_orders: return - filtered_self.activity_unlink(['sale.mail_act_sale_upsell']) - for order in filtered_self: - order.activity_schedule( - 'sale.mail_act_sale_upsell', - user_id=order.user_id.id, - note=_("Upsell %s for customer %s") % ( - order._name, order.id, order.name, - order.partner_id._name, order.partner_id.id, order.partner_id.display_name)) + upselling_orders._create_upsell_activity() def copy_data(self, default=None): if default is None: @@ -549,6 +542,17 @@ class SaleOrder(models.Model): return self._search(domain, limit=limit, access_rights_uid=name_get_uid) return super(SaleOrder, self)._name_search(name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid) + def _create_upsell_activity(self): + self and self.activity_unlink(['sale.mail_act_sale_upsell']) + for order in self: + ref = "%s" + order_ref = ref % (order._name, order.id, order.name) + customer_ref = ref % (order.partner_id._name, order.partner_id.id, order.partner_id.display_name) + order.activity_schedule( + 'sale.mail_act_sale_upsell', + user_id=order.user_id.id or order.partner_id.user_id.id, + note=_("Upsell %(order)s for customer %(customer)s", order=order_ref, customer=customer_ref)) + def _prepare_invoice(self): """ Prepare the dict of values to create the new invoice for a sales order. This method may be diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py index ce16030a947..8067d837487 100644 --- a/addons/sale_timesheet/models/product.py +++ b/addons/sale_timesheet/models/product.py @@ -21,6 +21,8 @@ class ProductTemplate(models.Model): # override domain project_id = fields.Many2one(domain="[('allow_billable', '=', True), ('pricing_type', '=', 'task_rate'), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet' or '', True])]") project_template_id = fields.Many2one(domain="[('allow_billable', '=', True), ('pricing_type', 'in', ('fixed_rate', 'employee_rate')), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet' or '', True])]") + service_upsell_warning = fields.Boolean('Upsell Warning', help="The salesperson in charge will be assigned an activity informing him of an upselling opportunity once the selected threshold is reached.") + service_upsell_threshold = fields.Float('Threshold', help="Percentage of time delivered compared to the prepaid amount that must be reached for the upselling opportunity activity to be triggered.") def _default_visible_expense_policy(self): visibility = self.user_has_groups('project.group_project_user') diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index 2f26feba436..8bb46eaa28e 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -5,6 +5,7 @@ import math from odoo import api, fields, models, _ from odoo.osv import expression +from odoo.tools import float_compare class SaleOrder(models.Model): @@ -42,6 +43,46 @@ class SaleOrder(models.Model): total_time *= sale_order.timesheet_encode_uom_id.factor sale_order.timesheet_total_duration = round(total_time) + def _compute_field_value(self, field): + super()._compute_field_value(field) + if field.name != 'invoice_status' or self.env.context.get('mail_activity_automation_skip'): + return + + # Get SOs which their state is not equal to upselling or invoied and if at least a SOL has warning prepaid service upsell set to True and the warning has not already been displayed + upsellable_orders = self.filtered(lambda so: + so.state == 'sale' + and so.invoice_status not in ('upselling', 'invoiced') + and (so.user_id or so.partner_id.user_id) # salesperson needed to assign upsell activity + ) + for order in upsellable_orders: + upsellable_lines = order._get_prepaid_service_lines_to_upsell() + if upsellable_lines: + order._create_upsell_activity() + # We want to display only one time the warning for each SOL + upsellable_lines.write({'has_displayed_warning_upsell': True}) + + def _get_prepaid_service_lines_to_upsell(self): + """ Retrieve all sols which need to display an upsell activity warning in the SO + + These SOLs should contain a product which has: + - type="service", + - service_policy="ordered_timesheet", + - service_upsell_warning=True. + """ + self.ensure_one() + precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') + return self.order_line.filtered(lambda sol: + sol.is_service + and not sol.has_displayed_warning_upsell # we don't want to display many times the warning each time we timesheet on the SOL + and sol.product_id.service_policy == 'ordered_timesheet' + and sol.product_id.service_upsell_warning + and float_compare( + sol.qty_delivered, + sol.product_uom_qty * (sol.product_id.service_upsell_threshold or 1.0), + precision_digits=precision + ) >= 0 + ) + def action_view_project_ids(self): self.ensure_one() # redirect to form or kanban view @@ -81,6 +122,7 @@ class SaleOrderLine(models.Model): analytic_line_ids = fields.One2many(domain=[('project_id', '=', False)]) # only analytic lines, not timesheets (since this field determine if SO line came from expense) remaining_hours_available = fields.Boolean(compute='_compute_remaining_hours_available', compute_sudo=True) remaining_hours = fields.Float('Remaining Hours on SO', compute='_compute_remaining_hours', compute_sudo=True, store=True) + has_displayed_warning_upsell = fields.Boolean('Has Displayed Warning Upsell') def name_get(self): res = super(SaleOrderLine, self).name_get() diff --git a/addons/sale_timesheet/tests/__init__.py b/addons/sale_timesheet/tests/__init__.py index 4f78edeef69..dd7859481f1 100644 --- a/addons/sale_timesheet/tests/__init__.py +++ b/addons/sale_timesheet/tests/__init__.py @@ -9,3 +9,4 @@ from . import test_reinvoice from . import test_reporting from . import test_project_overview from . import test_project_billing_multicompany +from . import test_upsell_warning diff --git a/addons/sale_timesheet/tests/test_upsell_warning.py b/addons/sale_timesheet/tests/test_upsell_warning.py new file mode 100644 index 00000000000..e7e7fda1762 --- /dev/null +++ b/addons/sale_timesheet/tests/test_upsell_warning.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import tagged + +from .common import TestCommonSaleTimesheet + + +@tagged('-at_install', 'post_install') +class TestUpsellWarning(TestCommonSaleTimesheet): + def test_display_upsell_warning(self): + """ Test to display an upsell warning + + + We display an upsell warning in SO when this following condition is satisfy in its SOL: + (qty_delivered / product_uom_qty) >= product_id.service_upsell_threshold + + Test Case: + ========= + 1) Configure the upsell warning in prepaid service product + 2) Create SO with a SOL containing this updated product, + 3) Create Project and Task, + 4) Timesheet in the task to satisfy the condition for the SOL to display an upsell warning, + 5) Check if the SO has an 'sale.mail_act_sale_upsell' activity. + """ + # 1) Configure the upsell warning in prepaid service product + self.product_order_timesheet1.write({ + 'service_upsell_warning': True, + 'service_upsell_threshold': 0.6, + }) + + # 2) Create SO with a SOL containing this updated product + so = self.env['sale.order'].create({ + 'partner_id': self.partner_a.id, + 'partner_invoice_id': self.partner_a.id, + 'partner_shipping_id': self.partner_a.id, + }) + + self.env['sale.order.line'].create({ + 'order_id': so.id, + 'name': self.product_order_timesheet1.name, + 'product_id': self.product_order_timesheet1.id, + 'product_uom_qty': 10, + 'price_unit': self.product_order_timesheet1.list_price, + }) + so.action_confirm() + + # 3) Create Project and Task + project = self.env['project.project'].create({ + 'name': 'Project', + 'allow_timesheets': True, + 'allow_billable': True, + 'partner_id': self.partner_a.id, + 'analytic_account_id': self.analytic_account_sale.id, + }) + task = self.env['project.task'].create({ + 'name': 'Task Test', + 'project_id': project.id, + }) + task._compute_sale_line() + + # 4) Timesheet in the task to satisfy the condition for the SOL to display an upsell warning + timesheet = self.env['account.analytic.line'].create({ + 'name': 'Test Line', + 'unit_amount': 5, + 'employee_id': self.employee_manager.id, + 'project_id': project.id, + 'task_id': task.id, + }) + timesheet._compute_so_line() + so.order_line._compute_qty_delivered() + so.order_line._compute_invoice_status() + so._get_invoice_status() + # Normally this method is called at the end of _get_invoice_status and other compute method. Here, we simulate for invoice_status field + so._compute_field_value(so._fields['invoice_status']) + + self.assertEqual(len(so.activity_search(['sale.mail_act_sale_upsell'])), 0, 'No upsell warning should appear in the SO.') + timesheet.write({ + 'unit_amount': 6, + }) + timesheet._compute_so_line() + so.order_line._compute_qty_delivered() + so.order_line._compute_invoice_status() + so._get_invoice_status() + # Normally this method is called at the end of _get_invoice_status and other compute method. Here, we simulate for invoice_status field + so._compute_field_value(so._fields['invoice_status']) + + # 5) Check if the SO has an 'sale.mail_act_sale_upsell' activity. + self.assertEqual(len(so.activity_search(['sale.mail_act_sale_upsell'])), 1, 'A upsell warning should appear in the SO.') diff --git a/addons/sale_timesheet/views/product_views.xml b/addons/sale_timesheet/views/product_views.xml index d238c6ea8ca..b14eb21afa4 100644 --- a/addons/sale_timesheet/views/product_views.xml +++ b/addons/sale_timesheet/views/product_views.xml @@ -11,6 +11,8 @@ + +