diff --git a/sale_stock_available_to_promise_release_block/__init__.py b/sale_stock_available_to_promise_release_block/__init__.py index 0650744f6b..a0f653930e 100644 --- a/sale_stock_available_to_promise_release_block/__init__.py +++ b/sale_stock_available_to_promise_release_block/__init__.py @@ -1 +1,3 @@ from . import models +from . import wizards +from .hooks import post_init_hook diff --git a/sale_stock_available_to_promise_release_block/__manifest__.py b/sale_stock_available_to_promise_release_block/__manifest__.py index 1b3b34dfa2..20e32ef4f6 100644 --- a/sale_stock_available_to_promise_release_block/__manifest__.py +++ b/sale_stock_available_to_promise_release_block/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Stock Available to Promise Release - Block from Sales", "summary": """Block release of deliveries from sales orders.""", - "version": "16.0.1.0.0", + "version": "16.0.1.1.0", "license": "AGPL-3", "author": "Camptcamp, ACSONE SA/NV, BCIM, Odoo Community Association (OCA)", "website": "https://github.com/OCA/wms", @@ -15,8 +15,12 @@ "stock_available_to_promise_release_block", ], "data": [ + "security/ir.model.access.csv", "views/sale_order.xml", "views/sale_order_line.xml", + "views/stock_move.xml", + "wizards/unblock_release.xml", ], "installable": True, + "post_init_hook": "post_init_hook", } diff --git a/sale_stock_available_to_promise_release_block/hooks.py b/sale_stock_available_to_promise_release_block/hooks.py new file mode 100644 index 0000000000..e976062791 --- /dev/null +++ b/sale_stock_available_to_promise_release_block/hooks.py @@ -0,0 +1,18 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo import SUPERUSER_ID, api + +_logger = logging.getLogger(__name__) + + +def post_init_hook(cr, registry): + _logger.info("Remove original 'Unblock Release' server action...") + env = api.Environment(cr, SUPERUSER_ID, {}) + action = env.ref( + "stock_available_to_promise_release_block.action_stock_move_unblock_release", + raise_if_not_found=False, + ) + action.unlink() diff --git a/sale_stock_available_to_promise_release_block/migrations/16.0.1.1.0/pre-migrate.py b/sale_stock_available_to_promise_release_block/migrations/16.0.1.1.0/pre-migrate.py new file mode 100644 index 0000000000..9ec4cdcd26 --- /dev/null +++ b/sale_stock_available_to_promise_release_block/migrations/16.0.1.1.0/pre-migrate.py @@ -0,0 +1,35 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +import logging + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + if not version: + return + remove_unblock_release_ir_action_server(cr) + + +def remove_unblock_release_ir_action_server(cr): + # The same XML-ID will be used by a new window action to open a wizard + _logger.info("Remove action 'action_sale_order_line_unblock_release'") + queries = [ + """ + DELETE FROM ir_act_server + WHERE id IN ( + SELECT res_id + FROM ir_model_data + WHERE module='sale_stock_available_to_promise_release_block' + AND name='action_sale_order_line_unblock_release' + AND model='ir.actions.server' + ); + """, + """ + DELETE FROM ir_model_data + WHERE module='sale_stock_available_to_promise_release_block' + AND name='action_sale_order_line_unblock_release'; + """, + ] + for query in queries: + cr.execute(query) diff --git a/sale_stock_available_to_promise_release_block/models/__init__.py b/sale_stock_available_to_promise_release_block/models/__init__.py index 0d2d587c10..e9668470db 100644 --- a/sale_stock_available_to_promise_release_block/models/__init__.py +++ b/sale_stock_available_to_promise_release_block/models/__init__.py @@ -1,3 +1,4 @@ +from . import stock_move from . import stock_rule from . import sale_order from . import sale_order_line diff --git a/sale_stock_available_to_promise_release_block/models/sale_order.py b/sale_stock_available_to_promise_release_block/models/sale_order.py index e9630f9dca..1c81e774bb 100644 --- a/sale_stock_available_to_promise_release_block/models/sale_order.py +++ b/sale_stock_available_to_promise_release_block/models/sale_order.py @@ -2,7 +2,7 @@ # Copyright 2024 Camptocamp # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import fields, models +from odoo import api, fields, models class SaleOrder(models.Model): @@ -14,3 +14,93 @@ class SaleOrder(models.Model): states={"draft": [("readonly", False)]}, help="Block the release of the generated delivery at order confirmation.", ) + available_move_to_unblock_ids = fields.One2many( + comodel_name="stock.move", + compute="_compute_available_move_to_unblock_ids", + string="Available moves to unblock", + help="Available moves to unblock for this order.", + ) + available_move_to_unblock_count = fields.Integer( + compute="_compute_available_move_to_unblock_ids" + ) + move_to_unblock_ids = fields.One2many( + comodel_name="stock.move", + inverse_name="unblocked_by_order_id", + string="Moves To Unblock", + readonly=True, + help="Moves to unblock when the current order is confirmed.", + ) + move_to_unblock_count = fields.Integer(compute="_compute_move_to_unblock_count") + + def _domain_available_move_to_unblock(self): + self.ensure_one() + # Returns domain for moves: + # - of type delivery + # - sharing the same shipping address + # - not yet release and blocked + return [ + ("picking_type_id.code", "=", "outgoing"), + ("partner_id", "=", self.partner_shipping_id.id), + ("state", "=", "waiting"), + ("need_release", "=", True), + ("release_blocked", "=", True), + ("unblocked_by_order_id", "!=", self.id), + ] + + @api.depends("order_line.move_ids") + def _compute_available_move_to_unblock_ids(self): + for order in self: + moves = self.env["stock.move"].search( + order._domain_available_move_to_unblock() + ) + self.available_move_to_unblock_ids = moves + self.available_move_to_unblock_count = len(moves) + + @api.depends("move_to_unblock_ids") + def _compute_move_to_unblock_count(self): + for order in self: + order.move_to_unblock_count = len(order.move_to_unblock_ids) + + def action_open_move_need_release(self): + action = super().action_open_move_need_release() + if not action.get("context"): + action["context"] = {} + action["context"].update(from_sale_order_id=self.id) + return action + + def action_open_available_move_to_unblock(self): + self.ensure_one() + if not self.available_move_to_unblock_count: + return + xmlid = "stock_available_to_promise_release.stock_move_release_action" + action = self.env["ir.actions.act_window"]._for_xml_id(xmlid) + action["domain"] = [("id", "in", self.available_move_to_unblock_ids.ids)] + action["context"] = {"from_sale_order_id": self.id} + return action + + def action_open_move_to_unblock(self): + self.ensure_one() + if not self.move_to_unblock_count: + return + xmlid = "stock_available_to_promise_release.stock_move_release_action" + action = self.env["ir.actions.act_window"]._for_xml_id(xmlid) + action["domain"] = [("id", "in", self.move_to_unblock_ids.ids)] + action["context"] = {} + return action + + def action_confirm(self): + # Reschedule the blocked moves when confirming the order + # NOTE: If a module like 'stock_picking_group_by_partner_by_carrier_by_date' + # is installed, these moves + the new ones generated by the current order + # will all be grouped in the same delivery order as soon as they share + # the same grouping criteria (partner, date, carrier...). + for order in self: + if order.move_to_unblock_ids: + date_deadline = order.commitment_date or order.expected_date + self.env["unblock.release"]._reschedule_moves( + order.move_to_unblock_ids, date_deadline, from_order=order + ) + # Unblock the release + if not order.block_release: + order.move_to_unblock_ids.action_unblock_release() + return super().action_confirm() diff --git a/sale_stock_available_to_promise_release_block/models/stock_move.py b/sale_stock_available_to_promise_release_block/models/stock_move.py new file mode 100644 index 0000000000..75c88cd45b --- /dev/null +++ b/sale_stock_available_to_promise_release_block/models/stock_move.py @@ -0,0 +1,16 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class StockMove(models.Model): + _inherit = "stock.move" + + unblocked_by_order_id = fields.Many2one( + comodel_name="sale.order", + ondelete="set null", + string="Unblocked by order", + readonly=True, + index=True, + ) diff --git a/sale_stock_available_to_promise_release_block/readme/DESCRIPTION.rst b/sale_stock_available_to_promise_release_block/readme/DESCRIPTION.rst index 4ab6b667f2..0eac4ac4bd 100644 --- a/sale_stock_available_to_promise_release_block/readme/DESCRIPTION.rst +++ b/sale_stock_available_to_promise_release_block/readme/DESCRIPTION.rst @@ -1 +1,12 @@ -Block release of deliveries from sale orders. +Block and unblock release of deliveries from sale orders. + +Release of deliveries can be blocked right after the sale order confirmation. + +When encoding a new order sharing the same delivery address, the user can +list the existing blocked deliveries (backorders) and plan to unblock them +when this new order is confirmed, making the existing deliveries and the new +ones sharing the same scheduled dates and deadlines. + +As a side-effect, this will leverage the module +`stock_picking_group_by_partner_by_carrier_by_date` if this one is installed, +by grouping all delivery lines within the same delivery order. diff --git a/sale_stock_available_to_promise_release_block/security/ir.model.access.csv b/sale_stock_available_to_promise_release_block/security/ir.model.access.csv new file mode 100644 index 0000000000..e4b605c825 --- /dev/null +++ b/sale_stock_available_to_promise_release_block/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_unblock_release_sale,access.unblock.release,model_unblock_release,sales_team.group_sale_salesman,1,1,1,0 +access_unblock_release_stock,access.unblock.release,model_unblock_release,stock.group_stock_user,1,1,1,0 diff --git a/sale_stock_available_to_promise_release_block/tests/test_sale_block_release.py b/sale_stock_available_to_promise_release_block/tests/test_sale_block_release.py index 1ef44f80fa..b97b546887 100644 --- a/sale_stock_available_to_promise_release_block/tests/test_sale_block_release.py +++ b/sale_stock_available_to_promise_release_block/tests/test_sale_block_release.py @@ -1,10 +1,23 @@ # Copyright 2024 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from odoo import exceptions, fields +from odoo.tests.common import Form + from odoo.addons.sale_stock_available_to_promise_release.tests import common class TestSaleBlockRelease(common.Common): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Ensure there is no security lead during tests + cls.env.company.security_lead = 0 + # Deliver in two steps to get a SHIP to release + cls.wh = cls.env.ref("stock.warehouse0") + cls.wh.delivery_steps = "pick_ship" + cls.wh.delivery_route_id.available_to_promise_defer_pull = True + def test_sale_release_not_blocked(self): self._set_stock(self.line.product_id, self.line.product_uom_qty) self.assertFalse(self.sale.block_release) @@ -16,3 +29,217 @@ def test_sale_release_blocked(self): self.sale.block_release = True self.sale.action_confirm() self.assertTrue(self.sale.picking_ids.release_blocked) + + def _create_unblock_release_wizard( + self, records=None, date_deadline=None, from_order=None, option="manual" + ): + wiz_form = Form( + self.env["unblock.release"].with_context( + from_sale_order_id=from_order and from_order.id, + active_model=records._name, + active_ids=records.ids, + default_option=option, + ) + ) + if date_deadline: + wiz_form.date_deadline = date_deadline + return wiz_form.save() + + def test_unblock_release_contextual(self): + self._set_stock(self.line.product_id, self.line.product_uom_qty) + self.sale.block_release = True + self.sale.action_confirm() + existing_moves = self.sale.order_line.move_ids + # Unblock deliveries through the wizard, opened from another SO + new_sale = self._create_sale_order() + self.env["sale.order.line"].create( + { + "order_id": new_sale.id, + "product_id": self.product.id, + "product_uom_qty": 50, + "product_uom": self.uom_unit.id, + } + ) + new_sale.commitment_date = fields.Datetime.add(fields.Datetime.now(), days=1) + self.assertIn(existing_moves, new_sale.available_move_to_unblock_ids) + wiz = self._create_unblock_release_wizard( + self.sale.order_line, from_order=new_sale + ) + self.assertEqual(wiz.option, "contextual") + self.assertEqual(wiz.order_id, new_sale) + self.assertEqual(wiz.date_deadline, new_sale.commitment_date) + self.assertNotEqual(wiz.order_line_ids.move_ids.date, new_sale.commitment_date) + old_picking = wiz.order_line_ids.move_ids.picking_id + wiz.validate() + # Deliveries will be unblocked when the new SO is confirmed + self.assertFalse(new_sale.available_move_to_unblock_ids) + self.assertEqual(new_sale.move_to_unblock_ids, existing_moves) + # Confirm the new SO: deliveries have been scheduled to the new date deadline + new_sale.action_confirm() + new_moves = new_sale.order_line.move_ids + new_picking = wiz.order_line_ids.move_ids.picking_id + self.assertNotEqual(old_picking, new_picking) + self.assertFalse(old_picking.exists()) + self.assertTrue( + all( + m.date == m.date_deadline == new_sale.commitment_date + for m in (existing_moves | new_moves) + ) + ) + self.assertTrue( + all(not m.release_blocked for m in (existing_moves | new_moves)) + ) + + def test_unblock_release_contextual_order_not_eligible(self): + self._set_stock(self.line.product_id, self.line.product_uom_qty) + self.sale.block_release = True + self.sale.action_confirm() + # Unblock deliveries through the wizard, opened from another SO + new_sale = self._create_sale_order() + self.env["sale.order.line"].create( + { + "order_id": new_sale.id, + "product_id": self.product.id, + "product_uom_qty": 50, + "product_uom": self.uom_unit.id, + } + ) + new_sale.action_cancel() + wiz = self._create_unblock_release_wizard( + self.sale.order_line, + from_order=new_sale, + date_deadline=fields.Datetime.now(), + ) + self.assertEqual(wiz.option, "manual") + + def test_unblock_release_contextual_different_shipping_policy(self): + self._set_stock(self.line.product_id, self.line.product_uom_qty) + self.sale.block_release = True + self.sale.action_confirm() + existing_moves = self.sale.order_line.move_ids + # Unblock deliveries through the wizard, opened from another SO with a + # different shipping_policy + new_sale = self._create_sale_order() + new_sale.picking_policy = "one" + self.env["sale.order.line"].create( + { + "order_id": new_sale.id, + "product_id": self.product.id, + "product_uom_qty": 50, + "product_uom": self.uom_unit.id, + } + ) + new_sale.commitment_date = fields.Datetime.add(fields.Datetime.now(), days=1) + self.assertIn(existing_moves, new_sale.available_move_to_unblock_ids) + wiz = self._create_unblock_release_wizard( + self.sale.order_line, from_order=new_sale + ) + self.assertEqual(wiz.option, "contextual") + self.assertEqual(wiz.order_id, new_sale) + self.assertEqual(wiz.date_deadline, new_sale.commitment_date) + self.assertNotEqual(wiz.order_line_ids.move_ids.date, new_sale.commitment_date) + self.assertNotEqual( + wiz.order_line_ids.move_ids.group_id.move_type, new_sale.picking_policy + ) + old_picking = wiz.order_line_ids.move_ids.picking_id + wiz.validate() + # Deliveries will be unblocked when the new SO is confirmed + self.assertFalse(new_sale.available_move_to_unblock_ids) + self.assertEqual(new_sale.move_to_unblock_ids, existing_moves) + # Confirm the new SO: deliveries have been scheduled to the new date deadline + # with the same shipping policy + new_sale.action_confirm() + new_moves = new_sale.order_line.move_ids + new_picking = wiz.order_line_ids.move_ids.picking_id + self.assertNotEqual(old_picking, new_picking) + self.assertFalse(old_picking.exists()) + self.assertTrue( + all( + m.date == m.date_deadline == new_sale.commitment_date + for m in (existing_moves | new_moves) + ) + ) + self.assertTrue( + all(not m.release_blocked for m in (existing_moves | new_moves)) + ) + self.assertEqual( + existing_moves.group_id.move_type, new_moves.group_id.move_type + ) + + def test_unblock_release_manual(self): + self._set_stock(self.line.product_id, self.line.product_uom_qty) + self.sale.block_release = True + self.sale.action_confirm() + # Unblock deliveries through the wizard + new_date_deadline = fields.Datetime.add(fields.Datetime.now(), days=1) + wiz = self._create_unblock_release_wizard( + self.sale.order_line, date_deadline=new_date_deadline + ) + self.assertEqual(wiz.option, "manual") + self.assertEqual(wiz.date_deadline, new_date_deadline) + self.assertNotEqual(wiz.order_line_ids.move_ids.date, new_date_deadline) + old_picking = wiz.order_line_ids.move_ids.picking_id + wiz.validate() + # Deliveries have been scheduled to the new date deadline + new_picking = wiz.order_line_ids.move_ids.picking_id + self.assertEqual(wiz.order_line_ids.move_ids.date, new_date_deadline) + self.assertNotEqual(old_picking, new_picking) + self.assertFalse(old_picking.exists()) + + def test_unblock_release_automatic(self): + # Start with a blocked SO having a commitment date in the past + self._set_stock(self.line.product_id, self.line.product_uom_qty) + self.sale.block_release = True + yesterday = fields.Datetime.subtract(fields.Datetime.now(), days=1) + self.sale.commitment_date = yesterday + self.sale.action_confirm() + # Unblock deliveries through the wizard + wiz = self._create_unblock_release_wizard( + self.sale.order_line, option="automatic" + ) + today = wiz.date_deadline + self.assertEqual(wiz.option, "automatic") + self.assertEqual(wiz.date_deadline, today) + self.assertNotEqual(wiz.order_line_ids.move_ids.date, today) + old_picking = wiz.order_line_ids.move_ids.picking_id + wiz.validate() + # Deliveries have been scheduled for today + new_picking = wiz.order_line_ids.move_ids.picking_id + self.assertEqual(wiz.order_line_ids.move_ids.date, today) + self.assertNotEqual(old_picking, new_picking) + self.assertFalse(old_picking.exists()) + + def test_unblock_release_automatic_from_moves(self): + # Same test than above but running the wizard from moves. + # Start with a blocked SO having a commitment date in the past + self._set_stock(self.line.product_id, self.line.product_uom_qty) + self.sale.block_release = True + yesterday = fields.Datetime.subtract(fields.Datetime.now(), days=1) + self.sale.commitment_date = yesterday + self.sale.action_confirm() + # Unblock deliveries through the wizard + today = fields.Datetime.now() + wiz = self._create_unblock_release_wizard( + self.sale.order_line.move_ids, option="automatic" + ) + self.assertEqual(wiz.date_deadline, today) + self.assertNotEqual(wiz.move_ids.date, today) + old_picking = wiz.move_ids.picking_id + wiz.validate() + # Deliveries have been scheduled for today + new_picking = wiz.move_ids.picking_id + self.assertEqual(wiz.move_ids.date, today) + self.assertNotEqual(old_picking, new_picking) + self.assertFalse(old_picking.exists()) + + def test_unblock_release_past_date_deadline(self): + self._set_stock(self.line.product_id, self.line.product_uom_qty) + self.sale.block_release = True + self.sale.action_confirm() + # Try to unblock deliveries through the wizard with a scheduled date + # in the past + yesterday = fields.Datetime.subtract(fields.Datetime.now(), days=1) + with self.assertRaises(exceptions.ValidationError): + self._create_unblock_release_wizard( + self.sale.order_line, date_deadline=yesterday + ) diff --git a/sale_stock_available_to_promise_release_block/views/sale_order.xml b/sale_stock_available_to_promise_release_block/views/sale_order.xml index a96eb7c6a1..9004a000a2 100644 --- a/sale_stock_available_to_promise_release_block/views/sale_order.xml +++ b/sale_stock_available_to_promise_release_block/views/sale_order.xml @@ -8,6 +8,40 @@ sale.order + + + + + + + + diff --git a/sale_stock_available_to_promise_release_block/views/sale_order_line.xml b/sale_stock_available_to_promise_release_block/views/sale_order_line.xml index dc5bc18af7..0b018251a4 100644 --- a/sale_stock_available_to_promise_release_block/views/sale_order_line.xml +++ b/sale_stock_available_to_promise_release_block/views/sale_order_line.xml @@ -34,16 +34,13 @@ - + Unblock Release - - + unblock.release + form + new + list - code - - if records: - records.move_ids.action_unblock_release() - diff --git a/sale_stock_available_to_promise_release_block/views/stock_move.xml b/sale_stock_available_to_promise_release_block/views/stock_move.xml new file mode 100644 index 0000000000..0e5214d20a --- /dev/null +++ b/sale_stock_available_to_promise_release_block/views/stock_move.xml @@ -0,0 +1,34 @@ + + + + + + stock.move + + + + + + + + + + + Unblock Release + unblock.release + form + new + + list + + + diff --git a/sale_stock_available_to_promise_release_block/wizards/__init__.py b/sale_stock_available_to_promise_release_block/wizards/__init__.py new file mode 100644 index 0000000000..77fc1c0e80 --- /dev/null +++ b/sale_stock_available_to_promise_release_block/wizards/__init__.py @@ -0,0 +1 @@ +from . import unblock_release diff --git a/sale_stock_available_to_promise_release_block/wizards/unblock_release.py b/sale_stock_available_to_promise_release_block/wizards/unblock_release.py new file mode 100644 index 0000000000..a1bc7232b2 --- /dev/null +++ b/sale_stock_available_to_promise_release_block/wizards/unblock_release.py @@ -0,0 +1,138 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, api, exceptions, fields, models + + +class UnblockRelease(models.TransientModel): + _name = "unblock.release" + _description = "Unblock Release" + + order_line_ids = fields.Many2many( + comodel_name="sale.order.line", + string="Order Lines", + readonly=True, + ) + move_ids = fields.Many2many( + comodel_name="stock.move", + string="Delivery moves", + readonly=True, + ) + option = fields.Selection( + selection=lambda self: self._selection_option(), + default="automatic", + required=True, + help=( + "- Manual: schedule blocked deliveries at a given date;\n" + "- Automatic: schedule blocked deliveries as soon as possible;\n" + "- Based on current order: schedule blocked deliveries with the " + "contextual sale order." + ), + ) + order_id = fields.Many2one(comodel_name="sale.order", string="Order", readonly=True) + date_deadline = fields.Datetime( + compute="_compute_date_deadline", store=True, readonly=False, required=True + ) + + @api.constrains("date_deadline") + def _constrains_date_deadline(self): + today = fields.Date.today() + for rec in self: + if rec.date_deadline.date() < today: + raise exceptions.ValidationError( + _("You cannot reschedule deliveries in the past.") + ) + + def _get_contextual_order(self): + """Return the current and eligible sale order from the context.""" + from_sale_order_id = self.env.context.get("from_sale_order_id") + order = self.env["sale.order"].browse(from_sale_order_id).exists() + if order and order.state not in ("sale", "done", "cancel"): + return order + + def _selection_option(self): + options = [ + ("manual", "Manual"), + ("automatic", "Automatic / As soon as possible"), + ] + order = self._get_contextual_order() + if order: + options.append(("contextual", "Based on current order")) + return options + + @api.depends("option") + def _compute_date_deadline(self): + from_sale_order_id = self.env.context.get("from_sale_order_id") + order = self.env["sale.order"].browse(from_sale_order_id).exists() + for rec in self: + rec.date_deadline = False + if rec.option == "automatic": + rec.date_deadline = fields.Datetime.now() + elif rec.option == "contextual" and order: + rec.date_deadline = order.commitment_date or order.expected_date + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + active_model = self.env.context.get("active_model") + active_ids = self.env.context.get("active_ids") + from_sale_order = self._get_contextual_order() + if from_sale_order: + res["order_id"] = from_sale_order.id + if active_model == "sale.order.line" and active_ids: + res["order_line_ids"] = [(6, 0, active_ids)] + if active_model == "stock.move" and active_ids: + res["move_ids"] = [(6, 0, active_ids)] + if from_sale_order: + res["option"] = "contextual" + return res + + def validate(self): + self.ensure_one() + moves = self._filter_moves(self.order_line_ids.move_ids or self.move_ids) + if self.option == "contextual": + self._plan_moves_for_current_order(moves) + else: + self._reschedule_moves(moves, self.date_deadline) + # Unblock release + moves.action_unblock_release() + + def _filter_moves(self, moves): + return moves.filtered_domain( + [("state", "=", "waiting"), ("release_blocked", "=", True)] + ) + + def _plan_moves_for_current_order(self, moves): + """Plan moves to be unblocked when the current order is confirmed.""" + self.order_id.move_to_unblock_ids = moves + + @api.model + def _reschedule_moves(self, moves, date_deadline, from_order=None): + """Reschedule the moves based on the deadline.""" + # Filter out moves that don't need to be released + moves = moves.filtered("need_release") + # Unset current deliveries (keep track of them to delete empty ones at the end) + pickings = moves.picking_id + moves.picking_id = False + # If the rescheduling is triggered from a sale order we set a dedicated + # procurement group on blocked moves. + # This has the side-effect to benefit from other modules like + # 'stock_picking_group_by_partner_by_carrier*' to get existing moves + # and new ones merged together if they share the same criteria + # (picking policy, carrier, scheduled date...). + if from_order: + group = self.env["procurement.group"].create( + fields.first(from_order.order_line)._prepare_procurement_group_vals() + ) + group.name += " BACKORDERS" + moves.group_id = group + # Update the scheduled date and date deadline + date_planned = fields.Datetime.subtract( + date_deadline, days=self.env.company.security_lead + ) + moves.date = date_planned + moves.date_deadline = date_deadline + # Re-assign deliveries + moves._assign_picking() + # Clean up empty deliveries + pickings.filtered(lambda o: not o.move_ids and not o.printed).unlink() diff --git a/sale_stock_available_to_promise_release_block/wizards/unblock_release.xml b/sale_stock_available_to_promise_release_block/wizards/unblock_release.xml new file mode 100644 index 0000000000..38503802e3 --- /dev/null +++ b/sale_stock_available_to_promise_release_block/wizards/unblock_release.xml @@ -0,0 +1,56 @@ + + + + + + unblock.release.form + unblock.release + + + + + + + + + + + + + + Selected delivery moves will be unblocked when the above order is confirmed. + Their delivery method, shipping policy and scheduled date will be aligned with the deliveries of the confirmed order. + + + + + + + +