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