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