From 27a6d5dfcc8db74b6afda63dd52ec8cf7854d172 Mon Sep 17 00:00:00 2001 From: Demesmaeker Date: Fri, 17 Dec 2021 09:18:49 +0000 Subject: [PATCH] [IMP] sale: ask confirmation for SO cancellation Before this commit, when cancelling a SO, the wizard opened only when there were draft invoices or delivered picking in the order. In order to complete the notification flow of the SO and avoid unexpected cancellation of orders, the user must now confirm cancellation and gets to possibility to send an email notification. task-2660895 Part-of: odoo/odoo#81569 --- .../models/event_booth_registration.py | 2 +- addons/event_sale/tests/test_event_sale.py | 2 +- .../tests/test_project_profitability.py | 2 +- addons/sale/data/mail_template_data.xml | 30 +++++++ addons/sale/models/sale_order.py | 36 ++++++-- addons/sale/tests/test_sale_order.py | 2 +- addons/sale/wizard/sale_order_cancel.py | 85 ++++++++++++++++++- .../sale/wizard/sale_order_cancel_views.xml | 46 ++++++++-- addons/sale_loyalty/models/sale_order.py | 4 +- .../test_program_with_code_operations.py | 2 +- .../sale_purchase/tests/test_access_rights.py | 2 +- .../sale_purchase/tests/test_sale_purchase.py | 2 +- .../tests/test_sale_purchase_stock_flow.py | 2 +- addons/sale_stock/tests/test_sale_stock.py | 4 +- .../wizard/sale_order_cancel_views.xml | 10 +-- .../sale_timesheet/tests/test_sale_service.py | 4 +- 16 files changed, 198 insertions(+), 37 deletions(-) diff --git a/addons/event_booth_sale/models/event_booth_registration.py b/addons/event_booth_sale/models/event_booth_registration.py index b80f66ea737..b8ca331f7cd 100644 --- a/addons/event_booth_sale/models/event_booth_registration.py +++ b/addons/event_booth_sale/models/event_booth_registration.py @@ -74,5 +74,5 @@ class EventBoothRegistration(models.Model): body=body, partner_ids=order.user_id.partner_id.ids, ) - order.sudo().action_cancel() + order.sudo()._action_cancel() other_registrations.unlink() diff --git a/addons/event_sale/tests/test_event_sale.py b/addons/event_sale/tests/test_event_sale.py index 1d043e9ea45..5f652c99cfd 100644 --- a/addons/event_sale/tests/test_event_sale.py +++ b/addons/event_sale/tests/test_event_sale.py @@ -269,5 +269,5 @@ class TestEventSale(TestEventSaleCommon): event = self.env['event.event'].browse(self.event_0.ids) self.register_person.action_make_registration() self.assertEqual(event.seats_expected, 1) - self.sale_order.action_cancel() + self.sale_order._action_cancel() self.assertEqual(event.seats_expected, 0) diff --git a/addons/project_sale_expense/tests/test_project_profitability.py b/addons/project_sale_expense/tests/test_project_profitability.py index 115d5cab34b..964fa270057 100644 --- a/addons/project_sale_expense/tests/test_project_profitability.py +++ b/addons/project_sale_expense/tests/test_project_profitability.py @@ -107,7 +107,7 @@ class TestProjectSaleExpenseProfitability(TestProjectProfitabilityCommon, TestPr {'id': 'expenses', 'sequence': expense_sequence, 'invoiced': 0.0, 'to_invoice': expense_sol.untaxed_amount_to_invoice}, ) - self.sale_order.action_cancel() + self.sale_order._action_cancel() expense_profitability = self.project._get_expenses_profitability_items(False) self.assertDictEqual( expense_profitability.get('revenues', {}), diff --git a/addons/sale/data/mail_template_data.xml b/addons/sale/data/mail_template_data.xml index 0c044e4a4ae..28c0939816c 100644 --- a/addons/sale/data/mail_template_data.xml +++ b/addons/sale/data/mail_template_data.xml @@ -231,5 +231,35 @@ {{ object.partner_id.lang }} + + + Sales Order: Cancellation email + + {{ object.company_id.name }} {{ object.type_name }} Cancelled (Ref {{ object.name or 'n/a' }}) + {{ (object.user_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }} + +
+

+ + Dear user, +

+ Please be advised that your + quotation S00052 + + (with reference: S00052 ) + + has been cancelled. Therefore, you should not be charged further for this order. + If any refund is necessary, this will be executed at best convenience. +

+ Do not hesitate to contact us if you have any questions. +
+

+
+
+ {{ object.partner_id.lang }} + +
+ diff --git a/addons/sale/models/sale_order.py b/addons/sale/models/sale_order.py index dfa8497c8fb..2efe19dd4dd 100644 --- a/addons/sale/models/sale_order.py +++ b/addons/sale/models/sale_order.py @@ -806,18 +806,41 @@ class SaleOrder(models.Model): self.write({'state': 'sale'}) def action_cancel(self): + """ Cancel SO after showing the cancel wizard when needed. (cfr `_show_cancel_wizard`) + + For post-cancel operations, please only override `_action_cancel`. + + note: self.ensure_one() if the wizard is shown. + """ cancel_warning = self._show_cancel_wizard() if cancel_warning: + self.ensure_one() + template_id = self.env['ir.model.data']._xmlid_to_res_id( + 'sale.mail_template_sale_cancellation', raise_if_not_found=False + ) + lang = self.env.context.get('lang') + template = self.env['mail.template'].browse(template_id) + if template.lang: + lang = template._render_lang(self.ids)[self.id] + ctx = { + 'default_use_template': bool(template_id), + 'default_template_id': template_id, + 'default_order_id': self.id, + 'mark_so_as_canceled': True, + 'default_email_layout_xmlid': "mail.mail_notification_paynow", + 'model_description': self.with_context(lang=lang).type_name, + } return { - 'name': _('Cancel Sales Order'), + 'name': _('Cancel %s', self.type_name), 'view_mode': 'form', 'res_model': 'sale.order.cancel', 'view_id': self.env.ref('sale.sale_order_cancel_view_form').id, 'type': 'ir.actions.act_window', - 'context': {'default_order_id': self.id}, + 'context': ctx, 'target': 'new' } - return self._action_cancel() + else: + return self._action_cancel() def _action_cancel(self): inv = self.invoice_ids.filtered(lambda inv: inv.state == 'draft') @@ -825,10 +848,9 @@ class SaleOrder(models.Model): return self.write({'state': 'cancel'}) def _show_cancel_wizard(self): - for order in self: - if order.invoice_ids.filtered(lambda inv: inv.state == 'draft') and not order._context.get('disable_cancel_warning'): - return True - return False + if self.env.context.get('disable_cancel_warning'): + return False + return any(so.state != 'draft' for so in self) def update_prices(self): self.ensure_one() diff --git a/addons/sale/tests/test_sale_order.py b/addons/sale/tests/test_sale_order.py index 965fa9cce27..8fc45637867 100644 --- a/addons/sale/tests/test_sale_order.py +++ b/addons/sale/tests/test_sale_order.py @@ -245,7 +245,7 @@ class TestSaleOrder(TestSaleCommon): so_copy = self.sale_order.copy() so_copy.action_confirm() self.assertTrue(so_copy.state == 'sale', 'Sale: SO should be in state "sale"') - so_copy.action_cancel() + so_copy._action_cancel() self.assertTrue(so_copy.state == 'cancel', 'Sale: SO should be in state "cancel"') with self.assertRaises(AccessError): so_copy.with_user(self.company_data['default_user_employee']).unlink() diff --git a/addons/sale/wizard/sale_order_cancel.py b/addons/sale/wizard/sale_order_cancel.py index b2ac441aaa5..878500c220f 100644 --- a/addons/sale/wizard/sale_order_cancel.py +++ b/addons/sale/wizard/sale_order_cancel.py @@ -1,20 +1,97 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools import formataddr class SaleOrderCancel(models.TransientModel): _name = 'sale.order.cancel' + _inherit = 'mail.composer.mixin' _description = "Sales Order Cancel" - order_id = fields.Many2one('sale.order', string='Sale Order', required=True, ondelete='cascade') - display_invoice_alert = fields.Boolean('Invoice Alert', compute='_compute_display_invoice_alert') + @api.model + def _default_email_from(self): + if self.env.user.email: + return formataddr((self.env.user.name, self.env.user.email)) + raise UserError(_("Unable to post message, please configure the sender's email address.")) + + @api.model + def _default_author_id(self): + return self.env.user.partner_id + + # origin + email_from = fields.Char(string="From", default=_default_email_from) + author_id = fields.Many2one( + 'res.partner', + string="Author", + index=True, + ondelete='set null', + default=_default_author_id, + ) + + # recipients + recipient_ids = fields.Many2many( + 'res.partner', + string="Recipients", + compute='_compute_recipient_ids', + readonly=False, + ) + order_id = fields.Many2one('sale.order', string="Sale Order", required=True, ondelete='cascade') + display_invoice_alert = fields.Boolean( + string="Invoice Alert", + compute='_compute_display_invoice_alert', + ) + + @api.depends('order_id') + def _compute_recipient_ids(self): + for wizard in self: + wizard.recipient_ids = wizard.order_id.partner_id \ + | wizard.order_id.message_partner_ids \ + - wizard.author_id @api.depends('order_id') def _compute_display_invoice_alert(self): for wizard in self: - wizard.display_invoice_alert = bool(wizard.order_id.invoice_ids.filtered(lambda inv: inv.state == 'draft')) + wizard.display_invoice_alert = bool( + wizard.order_id.invoice_ids.filtered(lambda inv: inv.state == 'draft') + ) + + @api.depends('order_id') + def _compute_subject(self): + for wizard in self: + if wizard.template_id: + wizard.subject = self.sudo()._render_template( + wizard.template_id.subject, + 'sale.order', + [wizard.order_id.id], + post_process=True, + )[wizard.order_id.id] + + @api.depends('order_id') + def _compute_body(self): + for wizard in self: + if wizard.template_id: + wizard.body = self.sudo()._render_template( + wizard.template_id.body_html, + 'sale.order', + [wizard.order_id.id], + post_process=True, + engine='qweb', + )[wizard.order_id.id] + + def action_send_mail_and_cancel(self): + self.ensure_one() + self.order_id.message_post( + subject=self.subject, + body=self.body, + message_type='comment', + email_from=self.email_from, + email_layout_xmlid='mail.mail_notification_light', + partner_ids=self.recipient_ids.ids, + ) + return self.action_cancel() def action_cancel(self): return self.order_id.with_context({'disable_cancel_warning': True}).action_cancel() diff --git a/addons/sale/wizard/sale_order_cancel_views.xml b/addons/sale/wizard/sale_order_cancel_views.xml index a33d0aec01e..f2c8d2b4dcd 100644 --- a/addons/sale/wizard/sale_order_cancel_views.xml +++ b/addons/sale/wizard/sale_order_cancel_views.xml @@ -5,14 +5,46 @@ sale.order.cancel
- - -
- Draft invoices for this order will be cancelled. -
+ + + + + + + + + + + + + +
-
diff --git a/addons/sale_loyalty/models/sale_order.py b/addons/sale_loyalty/models/sale_order.py index c9118105c69..1b46b4b6d52 100644 --- a/addons/sale_loyalty/models/sale_order.py +++ b/addons/sale_loyalty/models/sale_order.py @@ -74,9 +74,9 @@ class SaleOrder(models.Model): self._send_reward_coupon_mail() return super(SaleOrder, self).action_confirm() - def action_cancel(self): + def _action_cancel(self): previously_confirmed = self.filtered(lambda s: s.state in ('sale', 'done')) - res = super(SaleOrder, self).action_cancel() + res = super()._action_cancel() # Add/remove the points to our coupons for coupon, changes in previously_confirmed.filtered(lambda s: s.state not in ('sale', 'done'))._get_point_changes().items(): coupon.points -= changes diff --git a/addons/sale_loyalty/tests/test_program_with_code_operations.py b/addons/sale_loyalty/tests/test_program_with_code_operations.py index 2501ad484d5..2f52377c8f2 100644 --- a/addons/sale_loyalty/tests/test_program_with_code_operations.py +++ b/addons/sale_loyalty/tests/test_program_with_code_operations.py @@ -94,7 +94,7 @@ class TestProgramWithCodeOperations(TestSaleCouponCommon): self._apply_promo_code(sale_order_a, coupon.code) self.assertEqual(len(sale_order_a.order_line.ids), 2) - sale_order_a.action_cancel() + sale_order_a._action_cancel() sale_order_b.write({'order_line': [ (0, False, { diff --git a/addons/sale_purchase/tests/test_access_rights.py b/addons/sale_purchase/tests/test_access_rights.py index 936c3adc8f2..7e646723839 100644 --- a/addons/sale_purchase/tests/test_access_rights.py +++ b/addons/sale_purchase/tests/test_access_rights.py @@ -47,7 +47,7 @@ class TestAccessRights(TestCommonSalePurchaseNoChart): # confirming SO will create the PO even if you don't have the rights sale_order.action_confirm() - sale_order.action_cancel() + sale_order._action_cancel() self.assertTrue(sale_order.name, "Saleperson can read its own SO") diff --git a/addons/sale_purchase/tests/test_sale_purchase.py b/addons/sale_purchase/tests/test_sale_purchase.py index 7cf956ee380..307a3b02c2c 100644 --- a/addons/sale_purchase/tests/test_sale_purchase.py +++ b/addons/sale_purchase/tests/test_sale_purchase.py @@ -130,7 +130,7 @@ class TestSalePurchase(TestCommonSalePurchaseNoChart): self.assertEqual(purchase_line.price_unit, self.supplierinfo1.price, "Purchase line price is the one from the supplier") self.assertEqual(purchase_line.product_qty, self.sol1_service_purchase_1.product_uom_qty, "Quantity on SO line is not the same on the purchase line (same UoM)") - self.sale_order_1.action_cancel() + self.sale_order_1._action_cancel() self.assertEqual(len(purchase_order.activity_ids), 1, "One activity should be scheduled on the PO since a SO has been cancelled") diff --git a/addons/sale_purchase_stock/tests/test_sale_purchase_stock_flow.py b/addons/sale_purchase_stock/tests/test_sale_purchase_stock_flow.py index 126f90bc3ad..c283f6d9df5 100644 --- a/addons/sale_purchase_stock/tests/test_sale_purchase_stock_flow.py +++ b/addons/sale_purchase_stock/tests/test_sale_purchase_stock_flow.py @@ -37,7 +37,7 @@ class TestSalePurchaseStockFlow(TransactionCase): po = self.env['purchase.order'].search([('partner_id', '=', vendor.id)]) - so.action_cancel() + so._action_cancel() self.assertTrue(po.activity_ids) self.assertIn(so.name, po.activity_ids.note) diff --git a/addons/sale_stock/tests/test_sale_stock.py b/addons/sale_stock/tests/test_sale_stock.py index 52403be42c6..c30f4fc883a 100644 --- a/addons/sale_stock/tests/test_sale_stock.py +++ b/addons/sale_stock/tests/test_sale_stock.py @@ -422,7 +422,7 @@ class TestSaleStock(TestSaleCommon, ValuationReconciliationTestCommon): so1.action_confirm() self.assertEqual(len(so1.picking_ids), 1) self.assertEqual(so1.picking_ids.partner_id.id, partner1) - so1.action_cancel() + so1._action_cancel() so1.action_draft() so1.partner_id = partner2 so1.partner_shipping_id = partner2 # set by an onchange @@ -991,7 +991,7 @@ class TestSaleStock(TestSaleCommon, ValuationReconciliationTestCommon): self.assertEqual(inv_2.state, 'draft', 'invoice should be in draft state') # check the status of invoices after cancelling the order - so.action_cancel() + so._action_cancel() wizard = self.env['sale.order.cancel'].with_context({'order_id': so.id}).create({'order_id': so.id}) wizard.action_cancel() self.assertEqual(inv_1.state, 'posted', 'A posted invoice state should remain posted') diff --git a/addons/sale_stock/wizard/sale_order_cancel_views.xml b/addons/sale_stock/wizard/sale_order_cancel_views.xml index 3f98b5c5eb1..a672b0305dd 100644 --- a/addons/sale_stock/wizard/sale_order_cancel_views.xml +++ b/addons/sale_stock/wizard/sale_order_cancel_views.xml @@ -5,12 +5,12 @@ sale.order.cancel - + -
- Some products have already been delivered. Returns can be created from the Delivery Orders. -
-
+ + Some deliveries are already done. Returns can be created from the Delivery Orders. + +
diff --git a/addons/sale_timesheet/tests/test_sale_service.py b/addons/sale_timesheet/tests/test_sale_service.py index 54937a5dbb5..907aafa030b 100644 --- a/addons/sale_timesheet/tests/test_sale_service.py +++ b/addons/sale_timesheet/tests/test_sale_service.py @@ -230,7 +230,7 @@ class TestSaleService(TestCommonSaleTimesheet): self.assertEqual(so_line1.product_uom_qty, so_line1.task_id.planned_hours, "The planned hours should have changed when updating the ordered quantity of the native SO line") # cancel SO - self.sale_order.action_cancel() + self.sale_order._action_cancel() self.assertTrue(so_line1.task_id, "SO cancellation should keep the task") self.assertTrue(so_line1.project_id, "SO cancellation should create a project") @@ -509,7 +509,7 @@ class TestSaleService(TestCommonSaleTimesheet): sale_order_line.write({'product_uom_qty': 20}) self.assertEqual(sale_order_line.product_uom_qty, sale_order_line.task_id.planned_hours, "The planned hours should have changed when updating the ordered quantity of the native SO line") - self.sale_order.action_cancel() + self.sale_order._action_cancel() sale_order_line.write({'product_uom_qty': 30}) self.assertEqual(sale_order_line.product_uom_qty, sale_order_line.task_id.planned_hours, "The planned hours should have changed when updating the ordered quantity, even after SO cancellation")