[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:
@@ -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()
|
||||
|
||||
@@ -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', {}),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user