diff --git a/sale_order_line_cancel/README.rst b/sale_order_line_cancel/README.rst new file mode 100644 index 00000000000..e5eb0b49e34 --- /dev/null +++ b/sale_order_line_cancel/README.rst @@ -0,0 +1,96 @@ +====================== +Sale Order Line Cancel +====================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsale--workflow-lightgray.png?logo=github + :target: https://github.com/OCA/sale-workflow/tree/16.0/sale_order_line_cancel + :alt: OCA/sale-workflow +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/sale-workflow-16-0/sale-workflow-16-0-sale_order_line_cancel + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/webui/builds.html?repo=OCA/sale-workflow&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows you to cancel the remaining quantity on sale order by adding +a dedicated action to sale lines. It also add two new fields to track canceled +and remaining to deliver quantities. + +This module differs from the original odoo behavior in the following way: + +* In odoo, if the update of the quantity ordered is allowed on the sale order at + the confirmed state, odoo will recompute the required stock operations + according to the new quantity. This change is possible + even the stock operations are started for this sale order line. +* In this module, the quantity ordered is not updated on the sale order line to + keep track of the original ordered by the customer. At the same time, we + cancel only the stock moves for the remaining qty to deliver. This is only + possible if no operation is started for this sale order line. + + +.. warning:: + + It's not recommended to use this module if the update of the quantity ordered + on the sale order line is allowed the confirmed state. This could lead to + unpredictable behavior. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp +* ACSONE SA/NV + +Contributors +~~~~~~~~~~~~ + +* Sylvain Van Hoof +* Jacques-Etienne Baudoux (BCIM) +* Souheil Bejaoui + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/sale-workflow `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/sale_order_line_cancel/__init__.py b/sale_order_line_cancel/__init__.py new file mode 100644 index 00000000000..aee8895e7a3 --- /dev/null +++ b/sale_order_line_cancel/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/sale_order_line_cancel/__manifest__.py b/sale_order_line_cancel/__manifest__.py new file mode 100644 index 00000000000..c86db022eb6 --- /dev/null +++ b/sale_order_line_cancel/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2018 Sylvain Van Hoof (Okia SPRL) +# Copyright 2018 Jacques-Etienne Baudoux (BCIM) +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + "name": "Sale Order Line Cancel", + "version": "16.0.1.0.0", + "author": "Okia, BCIM, Camptocamp, ACSONE SA/NV, Odoo Community Association (OCA)", + "license": "AGPL-3", + "category": "Sales", + "summary": """Sale cancel remaining""", + "depends": ["sale_stock"], + "data": [ + "security/sale_order_line_cancel.xml", + "wizards/sale_order_line_cancel.xml", + "views/sale_order.xml", + "views/sale_order_line.xml", + ], + "website": "https://github.com/OCA/sale-workflow", +} diff --git a/sale_order_line_cancel/models/__init__.py b/sale_order_line_cancel/models/__init__.py new file mode 100644 index 00000000000..dc3e10cd560 --- /dev/null +++ b/sale_order_line_cancel/models/__init__.py @@ -0,0 +1,3 @@ +from . import sale_order_line +from . import stock_move +from . import sale_order diff --git a/sale_order_line_cancel/models/sale_order.py b/sale_order_line_cancel/models/sale_order.py new file mode 100644 index 00000000000..870d5136b38 --- /dev/null +++ b/sale_order_line_cancel/models/sale_order.py @@ -0,0 +1,22 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class SaleOrder(models.Model): + + _inherit = "sale.order" + + def action_draft(self): + res = super().action_draft() + orders = self.filtered(lambda s: s.state == "draft") + orders.order_line.write({"product_qty_canceled": 0}) + return res + + def _action_cancel(self): + res = super()._action_cancel() + orders = self.filtered(lambda s: s.state == "cancel") + for line in orders.order_line: + line.product_qty_canceled = line.product_uom_qty - line.qty_delivered + return res diff --git a/sale_order_line_cancel/models/sale_order_line.py b/sale_order_line_cancel/models/sale_order_line.py new file mode 100644 index 00000000000..119d6ab8cd5 --- /dev/null +++ b/sale_order_line_cancel/models/sale_order_line.py @@ -0,0 +1,77 @@ +# Copyright 2018 Okia SPRL +# Copyright 2018 Jacques-Etienne Baudoux (BCIM) +# Copyright 2020 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.tools import float_compare + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + product_qty_canceled = fields.Float( + "Qty canceled", readonly=True, copy=False, digits="Product Unit of Measure" + ) + product_qty_remains_to_deliver = fields.Float( + string="Remains to deliver", + digits="Product Unit of Measure", + compute="_compute_product_qty_remains_to_deliver", + store=True, + ) + can_cancel_remaining_qty = fields.Boolean( + compute="_compute_can_cancel_remaining_qty" + ) + + @api.depends("product_qty_remains_to_deliver", "state") + def _compute_can_cancel_remaining_qty(self): + precision = self.env["decimal.precision"].precision_get( + "Product Unit of Measure" + ) + for rec in self: + rec.can_cancel_remaining_qty = ( + float_compare( + rec.product_qty_remains_to_deliver, 0, precision_digits=precision + ) + == 1 + and rec.state in ("sale", "done") + and rec.qty_delivered_method == "stock_move" + ) + + @api.depends( + "product_uom_qty", + "qty_delivered", + "product_qty_canceled", + ) + def _compute_product_qty_remains_to_deliver(self): + for line in self: + qty_remaining = line.qty_to_deliver - line.product_qty_canceled + line.product_qty_remains_to_deliver = qty_remaining + + def _get_moves_to_cancel(self): + return self.move_ids.filtered(lambda m: m.state not in ("done", "cancel")) + + def _check_moves_to_cancel(self, moves): + """Override this method to add checks before cancel""" + self.ensure_one() + + def _update_qty_canceled(self): + """Update SO line qty canceled only when all remaining moves are canceled""" + for line in self: + if line._get_moves_to_cancel(): + continue + line.product_qty_canceled = line.qty_to_deliver + + def cancel_remaining_qty(self): + lines = self.filtered(lambda l: l.can_cancel_remaining_qty) + for line in lines: + moves_to_cancel = line._get_moves_to_cancel() + line._check_moves_to_cancel(moves_to_cancel) + moves_to_cancel._action_cancel() + line.order_id.message_post( + body=_( + "%(product)s: The order line has been canceled", + product=line.product_id.display_name, + ) + ) + return True diff --git a/sale_order_line_cancel/models/stock_move.py b/sale_order_line_cancel/models/stock_move.py new file mode 100644 index 00000000000..461150927f3 --- /dev/null +++ b/sale_order_line_cancel/models/stock_move.py @@ -0,0 +1,27 @@ +# Copyright 2023 ACSONE SA/NV +# Copyright 2024 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class StockMove(models.Model): + + _inherit = "stock.move" + + def _action_cancel(self): + sale_moves = self.filtered( + lambda m: m.sale_line_id and m.state not in ("done", "cancel") + ) + res = super()._action_cancel() + sale_lines = sale_moves.filtered(lambda m: m.state == "cancel").sale_line_id + sale_lines._update_qty_canceled() + return res + + def _action_done(self, cancel_backorder=False): + moves_todo = super()._action_done(cancel_backorder=cancel_backorder) + if cancel_backorder and moves_todo: + # _action_cancel is called before marking as done, so the hook on + # _action_cancel will not be triggered. Call it now + self.sale_line_id._update_qty_canceled() + return moves_todo diff --git a/sale_order_line_cancel/readme/CONTRIBUTORS.rst b/sale_order_line_cancel/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..6279060e914 --- /dev/null +++ b/sale_order_line_cancel/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* Sylvain Van Hoof +* Jacques-Etienne Baudoux (BCIM) +* Souheil Bejaoui diff --git a/sale_order_line_cancel/readme/DESCRIPTION.rst b/sale_order_line_cancel/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..c6b217a10ae --- /dev/null +++ b/sale_order_line_cancel/readme/DESCRIPTION.rst @@ -0,0 +1,21 @@ +This module allows you to cancel the remaining quantity on sale order by adding +a dedicated action to sale lines. It also add two new fields to track canceled +and remaining to deliver quantities. + +This module differs from the original odoo behavior in the following way: + +* In odoo, if the update of the quantity ordered is allowed on the sale order at + the confirmed state, odoo will recompute the required stock operations + according to the new quantity. This change is possible + even the stock operations are started for this sale order line. +* In this module, the quantity ordered is not updated on the sale order line to + keep track of the original ordered by the customer. At the same time, we + cancel only the stock moves for the remaining qty to deliver. This is only + possible if no operation is started for this sale order line. + + +.. warning:: + + It's not recommended to use this module if the update of the quantity ordered + on the sale order line is allowed the confirmed state. This could lead to + unpredictable behavior. diff --git a/sale_order_line_cancel/security/sale_order_line_cancel.xml b/sale_order_line_cancel/security/sale_order_line_cancel.xml new file mode 100644 index 00000000000..b2c475ed358 --- /dev/null +++ b/sale_order_line_cancel/security/sale_order_line_cancel.xml @@ -0,0 +1,14 @@ + + + + + sale.order.line.cancel access + + + + + + + + diff --git a/sale_order_line_cancel/static/description/index.html b/sale_order_line_cancel/static/description/index.html new file mode 100644 index 00000000000..c4da96cbc7d --- /dev/null +++ b/sale_order_line_cancel/static/description/index.html @@ -0,0 +1,441 @@ + + + + + + +Sale Order Line Cancel + + + +
+

Sale Order Line Cancel

+ + +

Beta License: AGPL-3 OCA/sale-workflow Translate me on Weblate Try me on Runboat

+

This module allows you to cancel the remaining quantity on sale order by adding +a dedicated action to sale lines. It also add two new fields to track canceled +and remaining to deliver quantities.

+

This module differs from the original odoo behavior in the following way:

+
    +
  • In odoo, if the update of the quantity ordered is allowed on the sale order at +the confirmed state, odoo will recompute the required stock operations +according to the new quantity. This change is possible +even the stock operations are started for this sale order line.
  • +
  • In this module, the quantity ordered is not updated on the sale order line to +keep track of the original ordered by the customer. At the same time, we +cancel only the stock moves for the remaining qty to deliver. This is only +possible if no operation is started for this sale order line.
  • +
+
+

Warning

+

It’s not recommended to use this module if the update of the quantity ordered +on the sale order line is allowed the confirmed state. This could lead to +unpredictable behavior.

+
+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/sale-workflow project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/sale_order_line_cancel/tests/__init__.py b/sale_order_line_cancel/tests/__init__.py new file mode 100644 index 00000000000..c3f12c4e152 --- /dev/null +++ b/sale_order_line_cancel/tests/__init__.py @@ -0,0 +1 @@ +from . import test_sale_order_line_cancel diff --git a/sale_order_line_cancel/tests/common.py b/sale_order_line_cancel/tests/common.py new file mode 100644 index 00000000000..0541a40aebe --- /dev/null +++ b/sale_order_line_cancel/tests/common.py @@ -0,0 +1,63 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import Command +from odoo.tests.common import TransactionCase + + +class TestSaleOrderLineCancelBase(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create({"name": "Partner"}) + cls.warehouse = cls.env.ref("stock.warehouse0") + cls.product_1 = cls.env["product.product"].create( + { + "name": "test product 1", + "type": "product", + "sale_ok": True, + "active": True, + } + ) + cls.product_2 = cls.product_1.copy({"name": "test product 2"}) + cls.product_3 = cls.product_1.copy({"name": "test product 3"}) + cls.sale = cls._add_done_sale_order() + cls.sale.action_done() + cls.wiz = cls.env["sale.order.line.cancel"].create({}) + cls.env["stock.quant"]._update_available_quantity( + cls.product_1, cls.warehouse.lot_stock_id, 10.0 + ) + + @classmethod + def _add_done_sale_order( + cls, partner=None, product=None, qty=10, picking_policy="direct" + ): + if partner is None: + partner = cls.partner + if product is None: + product = cls.product_1 + warehouse = cls.warehouse + sale_order_model = cls.env["sale.order"] + lines = [ + Command.create( + { + "name": p.name, + "product_id": p.id, + "product_uom_qty": qty, + "product_uom": p.uom_id.id, + "price_unit": 1, + }, + ) + for p in product + ] + so_values = { + "partner_id": partner.id, + "warehouse_id": warehouse.id, + "order_line": lines, + } + if picking_policy: + so_values["picking_policy"] = picking_policy + so = sale_order_model.create(so_values) + so.action_confirm() + so.action_done() + return so diff --git a/sale_order_line_cancel/tests/test_sale_order_line_cancel.py b/sale_order_line_cancel/tests/test_sale_order_line_cancel.py new file mode 100644 index 00000000000..5c6c1fd9dbf --- /dev/null +++ b/sale_order_line_cancel/tests/test_sale_order_line_cancel.py @@ -0,0 +1,113 @@ +# Copyright 2023 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from .common import TestSaleOrderLineCancelBase + + +class TestSaleOrderLineCancel(TestSaleOrderLineCancelBase): + def test_cancel_remaining_qty_not_started_picking(self): + line = self.sale.order_line + self.assertEqual(line.product_qty_remains_to_deliver, 10) + self.assertEqual(line.product_qty_canceled, 0) + self.wiz.with_context( + active_id=line.id, active_model="sale.order.line" + ).cancel_remaining_qty() + self.assertEqual(line.product_qty_remains_to_deliver, 0) + self.assertEqual(line.product_qty_canceled, 10) + + def test_cancel_backorder(self): + """check canceled qty set when backorder canceled""" + sale2 = self._add_done_sale_order(picking_policy="one") + line = sale2.order_line + ship = sale2.picking_ids + ship.move_ids.move_line_ids.qty_done = 5 + ship.with_context(cancel_backorder=True)._action_done() + self.assertEqual(ship.state, "done") + self.assertEqual(line.product_qty_canceled, 5) + self.assertEqual(line.product_qty_remains_to_deliver, 0) + + def test_keep_backorder(self): + """check canceled qty set when backorder canceled""" + sale2 = self._add_done_sale_order(picking_policy="one") + line = sale2.order_line + ship = sale2.picking_ids + ship.move_ids.move_line_ids.qty_done = 5 + ship.with_context(cancel_backorder=False)._action_done() + self.assertEqual(ship.state, "done") + self.assertEqual(line.product_qty_canceled, 0) + self.assertEqual(line.product_qty_remains_to_deliver, 5) + + def test_cancel_remaining_qty(self): + """check the outgoing pick is canceled""" + ship = self.sale.picking_ids + self.assertEqual(self.sale.order_line.product_qty_remains_to_deliver, 10) + self.wiz.with_context( + active_id=self.sale.order_line.id, active_model="sale.order.line" + ).cancel_remaining_qty() + self.assertEqual(ship.state, "cancel") + self.assertEqual(self.sale.order_line.product_qty_canceled, 10) + self.assertEqual(self.sale.order_line.product_qty_remains_to_deliver, 0) + + def test_cancel_pickings(self): + """if picking is canceled product_qty_canceled increased""" + self.assertTrue(self.sale.order_line.can_cancel_remaining_qty) + self.sale.picking_ids.action_cancel() + self.assertEqual(self.sale.order_line.product_qty_canceled, 10) + self.assertEqual(self.sale.order_line.product_qty_remains_to_deliver, 0) + self.assertFalse(self.sale.order_line.can_cancel_remaining_qty) + self.wiz.with_context( + active_id=self.sale.order_line.id, active_model="sale.order.line" + ).cancel_remaining_qty() + + def test_cancel_move_kit(self): + """when all remaining moves are canceled product_qty_canceled increased""" + self.assertTrue(self.sale.order_line.can_cancel_remaining_qty) + move = self.sale.picking_ids.move_ids + self.assertEqual(move.sale_line_id, self.sale.order_line) + # simulate a kit with a second move linked to the sale SO line + move2 = move.copy() + move2._action_confirm() + self.assertEqual(move2.sale_line_id, self.sale.order_line) + move._action_cancel() + self.assertEqual(self.sale.order_line.product_qty_canceled, 0) + move2._action_cancel() + self.assertEqual(self.sale.order_line.product_qty_canceled, 10) + self.assertEqual(self.sale.order_line.product_qty_remains_to_deliver, 0) + self.assertFalse(self.sale.order_line.can_cancel_remaining_qty) + self.wiz.with_context( + active_id=self.sale.order_line.id, active_model="sale.order.line" + ).cancel_remaining_qty() + + def test_reset_to_draft(self): + ship = self.sale.picking_ids + ship.action_assign() + ship.move_ids.move_line_ids.qty_done = 5 + ship.with_context(cancel_backorder=True)._action_done() + self.assertEqual(self.sale.order_line.product_qty_canceled, 5) + self.assertEqual(self.sale.order_line.product_qty_remains_to_deliver, 0) + self.sale.with_context(disable_cancel_warning=True).action_cancel() + self.assertEqual(self.sale.order_line.product_qty_canceled, 5) + self.assertEqual(self.sale.order_line.product_qty_remains_to_deliver, 0) + self.sale.action_draft() + self.assertEqual(self.sale.order_line.product_qty_canceled, 0) + self.assertEqual(self.sale.order_line.product_qty_remains_to_deliver, 5) + + def test_reset_to_draft_after_cancel(self): + ship = self.sale.picking_ids + ship.action_assign() + ship.move_ids.move_line_ids.qty_done = 5 + ship.with_context(cancel_backorder=False)._action_done() + self.assertEqual(self.sale.order_line.product_qty_canceled, 0) + self.assertEqual(self.sale.order_line.product_qty_remains_to_deliver, 5) + self.wiz.with_context( + active_id=self.sale.order_line.id, active_model="sale.order.line" + ).cancel_remaining_qty() + self.assertEqual(self.sale.order_line.product_qty_canceled, 5) + self.assertEqual(self.sale.order_line.product_qty_remains_to_deliver, 0) + self.sale.with_context(disable_cancel_warning=True).action_cancel() + self.assertEqual(self.sale.order_line.product_qty_canceled, 5) + self.assertEqual(self.sale.order_line.product_qty_remains_to_deliver, 0) + self.sale.action_draft() + self.assertEqual(self.sale.order_line.product_qty_canceled, 0) + self.assertEqual(self.sale.order_line.product_qty_remains_to_deliver, 5) diff --git a/sale_order_line_cancel/views/sale_order.xml b/sale_order_line_cancel/views/sale_order.xml new file mode 100644 index 00000000000..f67b5c0267c --- /dev/null +++ b/sale_order_line_cancel/views/sale_order.xml @@ -0,0 +1,49 @@ + + + + sale.order + + + + +