Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions sale_stock_cancel_restriction/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@
"license": "AGPL-3",
"development_status": "Production/Stable",
"depends": ["sale_stock"],
"data": ["views/stock_warehouse.xml"],
"installable": True,
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The version should be bumped (e.g. 16.0.1.1.0) since this PR adds a new feature (new field on stock.warehouse, new configurable behavior).

1 change: 1 addition & 0 deletions sale_stock_cancel_restriction/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from . import sale_order
from . import stock_warehouse
12 changes: 7 additions & 5 deletions sale_stock_cancel_restriction/models/sale_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ class SaleOrder(models.Model):
_inherit = "sale.order"

def action_cancel(self):
"""Force to call the cancel method on done picking for having the
expected error, as Odoo has now filter out such pickings from the
cancel operation.
"""
self.mapped("picking_ids").filtered(lambda r: r.state == "done").action_cancel()
# Force to call the cancel method on done picking for having the
# expected error, as Odoo has now filter out such pickings from the
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (multi-record recordset): self.warehouse_id will raise ValueError when self contains multiple sale orders with different warehouses (e.g. batch cancellation from list view). This should iterate per order:

for order in self:
    domain = [("state", "=", "done")]
    if order.warehouse_id.restrict_sale_cancel_after_delivery:
        domain += [("picking_type_id.code", "=", "outgoing")]
    order.picking_ids.filtered_domain(domain).action_cancel()
return super().action_cancel()

# cancel operation.
domain = [("state", "=", "done")]
if self.warehouse_id.restrict_sale_cancel_after_delivery:
domain += [("picking_type_id.code", "=", "outgoing")]
self.picking_ids.filtered_domain(domain).action_cancel()
return super().action_cancel()
17 changes: 17 additions & 0 deletions sale_stock_cancel_restriction/models/stock_warehouse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Copyright 2025 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)

from odoo import fields, models

HELP_RESTRICT = """
If set, will block cancellation of sale orders if any delivery is done.
Otherwise, it will block cancellation when any transfer is done.
"""


class StockWarehouse(models.Model):
_inherit = "stock.warehouse"

restrict_sale_cancel_after_delivery = fields.Boolean(
help=HELP_RESTRICT, default=False
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: default=False is redundant for Boolean fields in Odoo (False is already the default). Can be simplified to:

restrict_sale_cancel_after_delivery = fields.Boolean(help=HELP_RESTRICT)

)
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,29 +13,74 @@ def setUpClass(cls):
{"name": "Product test", "type": "product"}
)
cls.partner = cls.env["res.partner"].create({"name": "Partner test"})
cls.warehouse = cls.env.ref("stock.warehouse0")

@classmethod
def _create_sale_order(cls):
so_form = Form(cls.env["sale.order"])
so_form.partner_id = cls.partner
with so_form.order_line.new() as soline_form:
soline_form.product_id = cls.product
soline_form.product_uom_qty = 2
cls.sale_order = so_form.save()
cls.sale_order.action_confirm()
cls.picking = cls.sale_order.picking_ids
cls.picking.move_ids.quantity_done = 2
sale_order = so_form.save()
sale_order.action_confirm()
return sale_order

def test_cancel_sale_order_restrict(self):
"""Validates the picking and do the assertRaises cancelling the
order for checking that it's forbidden
"""
self.picking.button_validate()
sale_order = self._create_sale_order()
picking = sale_order.picking_ids
picking.move_ids.quantity_done = 2
picking.button_validate()
with self.assertRaises(UserError):
sale_order.action_cancel()

def test_cancel_sale_order_restrict_undelivered_picked(self):
# Enable restrict_sale_cancel_after_delivery, and multi step delivery.
# Cancel should be blocked only once picking is delivered
self.warehouse.restrict_sale_cancel_after_delivery = True
self.warehouse.delivery_steps = "pick_ship"
sale_order = self._create_sale_order()
pick_picking = sale_order.picking_ids.filtered(
lambda p: p.picking_type_id.code == "internal"
)
pick_picking.move_ids.quantity_done = 2
pick_picking.button_validate()
wizz = sale_order.action_cancel()
self.assertEqual(
wizz["res_model"],
"sale.order.cancel",
)

def test_cancel_sale_order_restrict_undelivered_shipped(self):
# Enable restrict_sale_cancel_after_delivery, and multi step delivery.
# Cancel should be blocked only once picking is delivered
self.warehouse.restrict_sale_cancel_after_delivery = True
self.warehouse.delivery_steps = "pick_ship"
sale_order = self._create_sale_order()
# Pick 2 units
pick_picking = sale_order.picking_ids.filtered(
lambda p: p.picking_type_id.code == "internal"
)
pick_picking.move_ids.quantity_done = 2
pick_picking.button_validate()
# Deliver 2 units
ship_picking = sale_order.picking_ids.filtered(
lambda p: p.picking_type_id.code == "outgoing"
)
ship_picking.move_ids.quantity_done = 2
ship_picking.button_validate()
with self.assertRaises(UserError):
self.sale_order.action_cancel()
sale_order.action_cancel()

def test_cancel_sale_order_ok(self):
"""When canceling the order, the wizard is generated with the
model 'sale.order.cancel
"""
wizz = self.sale_order.action_cancel()
sale_order = self._create_sale_order()
wizz = sale_order.action_cancel()
self.assertEqual(
wizz["res_model"],
"sale.order.cancel",
Expand Down
17 changes: 17 additions & 0 deletions sale_stock_cancel_restriction/views/stock_warehouse.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2025 Camptocamp SA
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>

<record id="view_warehouse" model="ir.ui.view">
<field name="name">stock.warehouse.form.inherit</field>
<field name="model">stock.warehouse</field>
<field name="inherit_id" ref="stock.view_warehouse" />
<field name="arch" type="xml">
<field name="partner_id" position="after">
<field name="restrict_sale_cancel_after_delivery" />
</field>
</field>
</record>

</odoo>
Loading