Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[16.0] sale_stock_available_to_promise_release_block: improve Unblock Release process #906

Open
wants to merge 2 commits into
base: 16.0
Choose a base branch
from
Open
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
12 changes: 8 additions & 4 deletions sale_stock_available_to_promise_release/tests/common.py
Original file line number Diff line number Diff line change
@@ -21,12 +21,13 @@ def setUpClassProduct(cls):
}
)

@classmethod
def _create_sale_order(cls):
return cls.env["sale.order"].create({"partner_id": cls.customer.id})

@classmethod
def setUpClassSale(cls):
customer = cls.env["res.partner"].create(
{"name": "Partner who loves storable products"}
)
cls.sale = cls.env["sale.order"].create({"partner_id": customer.id})
cls.sale = cls._create_sale_order()
cls.line = cls.env["sale.order.line"].create(
{
"order_id": cls.sale.id,
@@ -45,6 +46,9 @@ def setUpClassStock(cls):
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
cls.customer = cls.env["res.partner"].create(
{"name": "Partner who loves storable products"}
)
cls.setUpClassProduct()
cls.setUpClassSale()
cls.setUpClassStock()
2 changes: 2 additions & 0 deletions sale_stock_available_to_promise_release_block/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
from . import models
from . import wizards
from .hooks import post_init_hook
Original file line number Diff line number Diff line change
@@ -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",
}
18 changes: 18 additions & 0 deletions sale_stock_available_to_promise_release_block/hooks.py
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from . import stock_move
from . import stock_rule
from . import sale_order
from . import sale_order_line
Original file line number Diff line number Diff line change
@@ -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 @@
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()

Check warning on line 65 in sale_stock_available_to_promise_release_block/models/sale_order.py

Codecov / codecov/patch

sale_stock_available_to_promise_release_block/models/sale_order.py#L65

Added line #L65 was not covered by tests
if not action.get("context"):
action["context"] = {}
action["context"].update(from_sale_order_id=self.id)
return action

Check warning on line 69 in sale_stock_available_to_promise_release_block/models/sale_order.py

Codecov / codecov/patch

sale_stock_available_to_promise_release_block/models/sale_order.py#L67-L69

Added lines #L67 - L69 were not covered by tests

def action_open_available_move_to_unblock(self):
self.ensure_one()

Check warning on line 72 in sale_stock_available_to_promise_release_block/models/sale_order.py

Codecov / codecov/patch

sale_stock_available_to_promise_release_block/models/sale_order.py#L72

Added line #L72 was not covered by tests
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

Check warning on line 79 in sale_stock_available_to_promise_release_block/models/sale_order.py

Codecov / codecov/patch

sale_stock_available_to_promise_release_block/models/sale_order.py#L74-L79

Added lines #L74 - L79 were not covered by tests

def action_open_move_to_unblock(self):
self.ensure_one()

Check warning on line 82 in sale_stock_available_to_promise_release_block/models/sale_order.py

Codecov / codecov/patch

sale_stock_available_to_promise_release_block/models/sale_order.py#L82

Added line #L82 was not covered by tests
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

Check warning on line 89 in sale_stock_available_to_promise_release_block/models/sale_order.py

Codecov / codecov/patch

sale_stock_available_to_promise_release_block/models/sale_order.py#L84-L89

Added lines #L84 - L89 were not covered by tests

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()
16 changes: 16 additions & 0 deletions sale_stock_available_to_promise_release_block/models/stock_move.py
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
)
34 changes: 34 additions & 0 deletions sale_stock_available_to_promise_release_block/views/sale_order.xml
Original file line number Diff line number Diff line change
@@ -8,6 +8,40 @@
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form" />
<field name="arch" type="xml">
<div name="button_box" position="inside">
<button
name="action_open_available_move_to_unblock"
type="object"
class="oe_stat_button"
icon="fa-lock"
help="Deliveries that could be unblocked at order confirmation."
attrs="{'invisible': [
'|',
('available_move_to_unblock_count', '=', 0),
('state', 'in', ('sale', 'done'))
]}"
>
<field
name="available_move_to_unblock_count"
widget="statinfo"
string="Backorders"
/>
</button>
<button
name="action_open_move_to_unblock"
type="object"
class="oe_stat_button"
icon="fa-unlock"
help="Deliveries that will be unblocked at order confirmation."
attrs="{'invisible': [('move_to_unblock_count', '=', 0)]}"
>
<field
name="move_to_unblock_count"
widget="statinfo"
string="Backorders"
/>
</button>
</div>
<field name="payment_term_id" position="after">
<field name="block_release" />
</field>
Original file line number Diff line number Diff line change
@@ -34,16 +34,13 @@
</field>
</record>

<record id="action_sale_order_line_unblock_release" model="ir.actions.server">
<record id="action_sale_order_line_unblock_release" model="ir.actions.act_window">
<field name="name">Unblock Release</field>
<field name="model_id" ref="sale.model_sale_order_line" />
<field name="binding_model_id" ref="sale.model_sale_order_line" />
<field name="res_model">unblock.release</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="model_sale_order_line" />
<field name="binding_view_types">list</field>
<field name="state">code</field>
<field name="code">
if records:
records.move_ids.action_unblock_release()
</field>
</record>

<record id="action_sale_order_line_block_release" model="ir.actions.server">
34 changes: 34 additions & 0 deletions sale_stock_available_to_promise_release_block/views/stock_move.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2024 Camptocamp SA
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>

<record id="view_move_release_tree" model="ir.ui.view">
<field name="model">stock.move</field>
<field
name="inherit_id"
ref="stock_available_to_promise_release_block.view_move_release_tree"
/>
<field name="arch" type="xml">
<field name="release_blocked_label" position="after">
<field
name="unblocked_by_order_id"
widget="badge"
decoration-info="True"
optional="show"
/>
</field>
</field>
</record>

<!-- This new 'Unblock Release' window action replaces the original one (server action) -->
<record id="action_stock_move_unblock_release" model="ir.actions.act_window">
<field name="name">Unblock Release</field>
<field name="res_model">unblock.release</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="stock.model_stock_move" />
<field name="binding_view_types">list</field>
</record>

</odoo>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import unblock_release
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2024 Camptocamp SA
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>

<record id="unblock_release_view_form" model="ir.ui.view">
<field name="name">unblock.release.form</field>
<field name="model">unblock.release</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="option" />
<field
name="order_id"
force_save="1"
options="{'no_open': True}"
attrs="{'invisible': [('option', '!=', 'contextual')]}"
/>
<field
name="date_deadline"
attrs="{'invisible': [('option', 'in', ('automatic', 'contextual'))], 'required': [('option', '=', 'manual')]}"
/>
</group>
<group string="Selected deliveries" name="selected_deliveries">
<field
name="order_line_ids"
nolabel="1"
colspan="2"
attrs="{'invisible': [('order_line_ids', '=', [])]}"
/>
<field
name="move_ids"
nolabel="1"
colspan="2"
attrs="{'invisible': [('move_ids', '=', [])]}"
/>
</group>
<div
attrs="{'invisible': [('option', '!=', 'contextual')]}"
class="alert alert-warning"
role="alert"
>
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.
</div>
</sheet>
<footer>
<button name="validate" type="object" string="Validate" class="btn-primary" />
<button special="cancel" string="Cancel" class="btn-default" />
</footer>
</form>
</field>
</record>

</odoo>