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")