[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
This commit is contained in:
Demesmaeker
2022-05-09 19:05:40 +02:00
parent 53415c6b16
commit 27a6d5dfcc
16 changed files with 198 additions and 37 deletions
@@ -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()
+1 -1
View File
@@ -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)
@@ -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', {}),
+30
View File
@@ -231,5 +231,35 @@
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<record id="sale.mail_template_sale_cancellation" model="mail.template">
<field name="name">Sales Order: Cancellation email</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">{{ object.company_id.name }} {{ object.type_name }} Cancelled (Ref {{ object.name or 'n/a' }})</field>
<field name="email_from">{{ (object.user_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="margin: 0px; padding: 0px;">
<p style="margin: 0px; padding: 0px; font-size: 13px;">
<t t-set="doc_name" t-value="object.type_name"/>
Dear <t t-out="object.partner_id.name or ''">user</t>,
<br/><br/>
Please be advised that your
<t t-out="doc_name or ''">quotation</t> <strong t-out="object.name or ''">S00052</strong>
<t t-if="object.origin">
(with reference: <t t-out="object.origin or ''">S00052</t> )
</t>
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.
<br/><br/>
Do not hesitate to contact us if you have any questions.
<br/>
</p>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
</data>
</odoo>
+29 -7
View File
@@ -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()
+1 -1
View File
@@ -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()
+81 -4
View File
@@ -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()
+39 -7
View File
@@ -5,14 +5,46 @@
<field name="model">sale.order.cancel</field>
<field name="arch" type="xml">
<form>
<field name="order_id" invisible="1"/>
<field name="display_invoice_alert" invisible="1"/>
<div attrs="{'invisible': [('display_invoice_alert', '=', False)]}">
Draft invoices for this order will be cancelled.
</div>
<group col="1">
<field name="render_model" invisible="1"/>
<field name="order_id" invisible="1"/>
<field name="template_id" invisible="1"/>
<field name="display_invoice_alert" invisible="1"/>
<div col="2"
class="alert alert-warning"
role="alert">
<span>Are you sure you want to cancel this order? <br/></span>
<span id="display_invoice_alert"
attrs="{'invisible': [('display_invoice_alert', '=', False)]}">
Draft invoices for this order will be cancelled. <br/>
</span>
</div>
<group col="2">
<field name="recipient_ids"
widget="many2many_tags_email"
context="{'force_email': True,
'show_email': True,
'no_create_edit': True}"/>
</group>
<group col="2">
<field name="subject" placeholder="Subject"/>
</group>
<field name="body"
class="oe-bordered-editor"
options="{'style-inline': true}"/>
</group>
<footer>
<button string="Confirm" name="action_cancel" type="object" class="oe_highlight" data-hotkey="q"/>
<button string="Cancel" class="btn btn-default" special="cancel" data-hotkey="z"/>
<button string="Send and cancel"
name="action_send_mail_and_cancel"
type="object"
class="btn-primary"/>
<button string="Cancel"
name="action_cancel"
type="object"
class="btn-primary mx-1"/>
<button string="Discard"
class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
+2 -2
View File
@@ -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
@@ -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, {
@@ -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")
@@ -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")
@@ -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)
+2 -2
View File
@@ -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')
@@ -5,12 +5,12 @@
<field name="model">sale.order.cancel</field>
<field name="inherit_id" ref="sale.sale_order_cancel_view_form"/>
<field name="arch" type="xml">
<field name="display_invoice_alert" position="after">
<span id="display_invoice_alert" position="after">
<field name="display_delivery_alert" invisible="1"/>
<div attrs="{'invisible': [('display_delivery_alert', '=', False)]}">
Some products have already been delivered. Returns can be created from the Delivery Orders.
</div>
</field>
<span attrs="{'invisible': [('display_delivery_alert', '=', False)]}">
Some deliveries are already done. Returns can be created from the Delivery Orders.
</span>
</span>
</field>
</record>
</odoo>
@@ -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")