Skip to content

Commit b09a14d

Browse files
committed
sale_stock_available_to_promise_release_block: add Unblock Release wizard
This new wizard allows to give more options to the user regarding the unblocking process, like the scheduled date to set on unblocked moves, and re-assign automatically a stock operation on them (so they could be grouped together in the same transfer).
1 parent 1976a48 commit b09a14d

File tree

12 files changed

+319
-9
lines changed

12 files changed

+319
-9
lines changed
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
from . import models
2+
from . import wizards
3+
from .hooks import post_init_hook

Diff for: sale_stock_available_to_promise_release_block/__manifest__.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
{
66
"name": "Stock Available to Promise Release - Block from Sales",
77
"summary": """Block release of deliveries from sales orders.""",
8-
"version": "16.0.1.0.0",
8+
"version": "16.0.1.1.0",
99
"license": "AGPL-3",
1010
"author": "Camptcamp, ACSONE SA/NV, BCIM, Odoo Community Association (OCA)",
1111
"website": "https://github.com/OCA/wms",
@@ -15,8 +15,12 @@
1515
"stock_available_to_promise_release_block",
1616
],
1717
"data": [
18+
"security/ir.model.access.csv",
1819
"views/sale_order.xml",
1920
"views/sale_order_line.xml",
21+
"views/stock_move.xml",
22+
"wizards/unblock_release.xml",
2023
],
2124
"installable": True,
25+
"post_init_hook": "post_init_hook",
2226
}
+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Copyright 2024 Camptocamp SA
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
3+
4+
import logging
5+
6+
from odoo import SUPERUSER_ID, api
7+
8+
_logger = logging.getLogger(__name__)
9+
10+
11+
def post_init_hook(cr, registry):
12+
_logger.info("Remove original 'Unblock Release' server action...")
13+
env = api.Environment(cr, SUPERUSER_ID, {})
14+
action = env.ref(
15+
"stock_available_to_promise_release_block.action_stock_move_unblock_release",
16+
raise_if_not_found=False,
17+
)
18+
action.unlink()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Copyright 2024 Camptocamp SA
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
3+
import logging
4+
5+
_logger = logging.getLogger(__name__)
6+
7+
8+
def migrate(cr, version):
9+
if not version:
10+
return
11+
remove_unblock_release_ir_action_server(cr)
12+
13+
14+
def remove_unblock_release_ir_action_server(cr):
15+
# The same XML-ID will be used by a new window action to open a wizard
16+
_logger.info("Remove action 'action_sale_order_line_unblock_release'")
17+
queries = [
18+
"""
19+
DELETE FROM ir_act_server
20+
WHERE id IN (
21+
SELECT res_id
22+
FROM ir_model_data
23+
WHERE module='sale_stock_available_to_promise_release_block'
24+
AND name='action_sale_order_line_unblock_release'
25+
AND model='ir.actions.server'
26+
);
27+
""",
28+
"""
29+
DELETE FROM ir_model_data
30+
WHERE module='sale_stock_available_to_promise_release_block'
31+
AND name='action_sale_order_line_unblock_release';
32+
""",
33+
]
34+
for query in queries:
35+
cr.execute(query)

Diff for: sale_stock_available_to_promise_release_block/models/sale_order.py

+7
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,10 @@ class SaleOrder(models.Model):
1414
states={"draft": [("readonly", False)]},
1515
help="Block the release of the generated delivery at order confirmation.",
1616
)
17+
18+
def action_open_move_need_release(self):
19+
action = super().action_open_move_need_release()
20+
if not action.get("context"):
21+
action["context"] = {}
22+
action["context"].update(from_sale_order_id=self.id)
23+
return action
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
2+
access_unblock_release_sale,access.unblock.release,model_unblock_release,sales_team.group_sale_salesman,1,1,1,0
3+
access_unblock_release_stock,access.unblock.release,model_unblock_release,stock.group_stock_user,1,1,1,0

Diff for: sale_stock_available_to_promise_release_block/tests/test_sale_block_release.py

+100
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
# Copyright 2024 Camptocamp SA
22
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
33

4+
import psycopg2
5+
6+
from odoo import fields
7+
from odoo.tests.common import Form
8+
49
from odoo.addons.sale_stock_available_to_promise_release.tests import common
510

611

712
class TestSaleBlockRelease(common.Common):
13+
@classmethod
14+
def setUpClass(cls):
15+
super().setUpClass()
16+
# Ensure there is no security lead during tests
17+
cls.env.company.security_lead = 0
18+
819
def test_sale_release_not_blocked(self):
920
self._set_stock(self.line.product_id, self.line.product_uom_qty)
1021
self.assertFalse(self.sale.block_release)
@@ -16,3 +27,92 @@ def test_sale_release_blocked(self):
1627
self.sale.block_release = True
1728
self.sale.action_confirm()
1829
self.assertTrue(self.sale.picking_ids.release_blocked)
30+
31+
def _create_unblock_release_wizard(
32+
self, order_lines, date_deadline=None, from_order=None, option="free"
33+
):
34+
wiz_form = Form(
35+
self.env["unblock.release"].with_context(
36+
from_sale_order_id=from_order and from_order.id,
37+
active_model=order_lines._name,
38+
active_ids=order_lines.ids,
39+
default_option=option,
40+
)
41+
)
42+
if date_deadline:
43+
wiz_form.date_deadline = date_deadline
44+
return wiz_form.save()
45+
46+
def test_sale_order_line_unblock_release_contextual(self):
47+
self._set_stock(self.line.product_id, self.line.product_uom_qty)
48+
self.sale.block_release = True
49+
self.sale.action_confirm()
50+
# Unblock deliveries through the wizard, opened from another SO
51+
# to define default values
52+
new_sale = self._create_sale_order()
53+
new_sale.commitment_date = fields.Datetime.add(fields.Datetime.now(), days=1)
54+
wiz = self._create_unblock_release_wizard(
55+
self.sale.order_line, from_order=new_sale
56+
)
57+
self.assertEqual(wiz.option, "contextual")
58+
self.assertEqual(wiz.date_deadline, new_sale.commitment_date)
59+
self.assertNotEqual(wiz.order_line_ids.move_ids.date, new_sale.commitment_date)
60+
old_picking = wiz.order_line_ids.move_ids.picking_id
61+
wiz.validate()
62+
# Deliveries have been scheduled to the new date deadline
63+
new_picking = wiz.order_line_ids.move_ids.picking_id
64+
self.assertEqual(wiz.order_line_ids.move_ids.date, new_sale.commitment_date)
65+
self.assertNotEqual(old_picking, new_picking)
66+
self.assertFalse(old_picking.exists())
67+
68+
def test_sale_order_line_unblock_release_free(self):
69+
self._set_stock(self.line.product_id, self.line.product_uom_qty)
70+
self.sale.block_release = True
71+
self.sale.action_confirm()
72+
# Unblock deliveries through the wizard
73+
new_date_deadline = fields.Datetime.add(fields.Datetime.now(), days=1)
74+
wiz = self._create_unblock_release_wizard(
75+
self.sale.order_line, date_deadline=new_date_deadline
76+
)
77+
self.assertEqual(wiz.date_deadline, new_date_deadline)
78+
self.assertNotEqual(wiz.order_line_ids.move_ids.date, new_date_deadline)
79+
old_picking = wiz.order_line_ids.move_ids.picking_id
80+
wiz.validate()
81+
# Deliveries have been scheduled to the new date deadline
82+
new_picking = wiz.order_line_ids.move_ids.picking_id
83+
self.assertEqual(wiz.order_line_ids.move_ids.date, new_date_deadline)
84+
self.assertNotEqual(old_picking, new_picking)
85+
self.assertFalse(old_picking.exists())
86+
87+
def test_sale_order_line_unblock_release_asap(self):
88+
# Start with a blocked SO having a commitment date in the past
89+
self._set_stock(self.line.product_id, self.line.product_uom_qty)
90+
self.sale.block_release = True
91+
yesterday = fields.Datetime.subtract(fields.Datetime.now(), days=1)
92+
self.sale.commitment_date = yesterday
93+
self.sale.action_confirm()
94+
# Unblock deliveries through the wizard
95+
today = fields.Datetime.now()
96+
wiz = self._create_unblock_release_wizard(self.sale.order_line, option="asap")
97+
self.assertEqual(wiz.date_deadline, today)
98+
self.assertNotEqual(wiz.order_line_ids.move_ids.date, today)
99+
old_picking = wiz.order_line_ids.move_ids.picking_id
100+
wiz.validate()
101+
# Deliveries have been scheduled for today
102+
new_picking = wiz.order_line_ids.move_ids.picking_id
103+
self.assertEqual(wiz.order_line_ids.move_ids.date, today)
104+
self.assertNotEqual(old_picking, new_picking)
105+
self.assertFalse(old_picking.exists())
106+
107+
def test_sale_order_line_unblock_release_past_date_deadline(self):
108+
self._set_stock(self.line.product_id, self.line.product_uom_qty)
109+
self.sale.block_release = True
110+
self.sale.action_confirm()
111+
# Try to unblock deliveries through the wizard with a scheduled date
112+
# in the past
113+
new_sale = self._create_sale_order()
114+
yesterday = fields.Datetime.subtract(fields.Datetime.now(), days=1)
115+
with self.assertRaises(psycopg2.errors.CheckViolation):
116+
self._create_unblock_release_wizard(
117+
self.sale.order_line, date_deadline=yesterday, from_order=new_sale
118+
)

Diff for: sale_stock_available_to_promise_release_block/views/sale_order_line.xml

+5-8
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,13 @@
3434
</field>
3535
</record>
3636

37-
<record id="action_sale_order_line_unblock_release" model="ir.actions.server">
37+
<record id="action_sale_order_line_unblock_release" model="ir.actions.act_window">
3838
<field name="name">Unblock Release</field>
39-
<field name="model_id" ref="sale.model_sale_order_line" />
40-
<field name="binding_model_id" ref="sale.model_sale_order_line" />
39+
<field name="res_model">unblock.release</field>
40+
<field name="view_mode">form</field>
41+
<field name="target">new</field>
42+
<field name="binding_model_id" ref="model_sale_order_line" />
4143
<field name="binding_view_types">list</field>
42-
<field name="state">code</field>
43-
<field name="code">
44-
if records:
45-
records.move_ids.action_unblock_release()
46-
</field>
4744
</record>
4845

4946
<record id="action_sale_order_line_block_release" model="ir.actions.server">
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?xml version="1.0" encoding="utf-8" ?>
2+
<!-- Copyright 2024 Camptocamp SA
3+
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
4+
<odoo>
5+
6+
<!-- This new 'Unblock Release' window action replaces the original one (server action) -->
7+
<record id="action_stock_move_unblock_release" model="ir.actions.act_window">
8+
<field name="name">Unblock Release</field>
9+
<field name="res_model">unblock.release</field>
10+
<field name="view_mode">form</field>
11+
<field name="target">new</field>
12+
<field name="binding_model_id" ref="stock.model_stock_move" />
13+
<field name="binding_view_types">list</field>
14+
</record>
15+
16+
</odoo>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import unblock_release
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Copyright 2024 Camptocamp SA
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
3+
4+
from odoo import api, fields, models
5+
6+
7+
class UnblockRelease(models.TransientModel):
8+
_name = "unblock.release"
9+
_description = "Unblock Release"
10+
11+
order_line_ids = fields.Many2many(
12+
comodel_name="sale.order.line",
13+
string="Order Lines",
14+
)
15+
move_ids = fields.Many2many(
16+
comodel_name="stock.move",
17+
string="Delivery moves",
18+
)
19+
option = fields.Selection(
20+
selection=lambda self: self._selection_option(),
21+
default="asap",
22+
required=True,
23+
)
24+
date_deadline = fields.Datetime(
25+
compute="_compute_date_deadline", store=True, readonly=False, required=True
26+
)
27+
28+
_sql_constraints = [
29+
(
30+
"check_scheduled_date",
31+
"CHECK (date_deadline::date >= now()::date)",
32+
"You cannot reschedule deliveries in the past.",
33+
),
34+
]
35+
36+
def _selection_option(self):
37+
options = [
38+
("free", "Free"),
39+
("asap", "As soon as possible"),
40+
]
41+
if self.env.context.get("from_sale_order_id"):
42+
options.append(("contextual", "From contextual sale order"))
43+
return options
44+
45+
@api.depends("option")
46+
def _compute_date_deadline(self):
47+
from_sale_order_id = self.env.context.get("from_sale_order_id")
48+
order = self.env["sale.order"].browse(from_sale_order_id).exists()
49+
for rec in self:
50+
rec.date_deadline = False
51+
if rec.option == "asap":
52+
rec.date_deadline = fields.Datetime.now()
53+
elif rec.option == "contextual" and order:
54+
rec.date_deadline = order.commitment_date or order.expected_date
55+
56+
@api.model
57+
def default_get(self, fields_list):
58+
res = super().default_get(fields_list)
59+
active_model = self.env.context.get("active_model")
60+
active_ids = self.env.context.get("active_ids")
61+
from_sale_order_id = self.env.context.get("from_sale_order_id")
62+
from_sale_order = self.env["sale.order"].browse(from_sale_order_id).exists()
63+
if active_model == "sale.order.line" and active_ids:
64+
res["order_line_ids"] = [(6, 0, active_ids)]
65+
if active_model == "stock.move" and active_ids:
66+
res["move_ids"] = [(6, 0, active_ids)]
67+
if from_sale_order:
68+
res["option"] = "contextual"
69+
return res
70+
71+
def validate(self):
72+
self.ensure_one()
73+
move_states = (
74+
"draft",
75+
"waiting",
76+
"confirmed",
77+
"partially_available",
78+
"assigned",
79+
)
80+
moves = (self.order_line_ids.move_ids or self.move_ids).filtered_domain(
81+
[("state", "in", move_states), ("release_blocked", "=", True)]
82+
)
83+
# Unset current deliveries (keep track of them to delete empty ones at the end)
84+
pickings = moves.picking_id
85+
moves.picking_id = False
86+
# Update the scheduled date
87+
date_planned = fields.Datetime.subtract(
88+
self.date_deadline, days=self.env.company.security_lead
89+
)
90+
moves.date = date_planned
91+
# Re-assign deliveries: moves sharing the same criteria - like date - will
92+
# be part of the same delivery.
93+
# NOTE: this will also leverage stock_picking_group_by_partner_by_carrier
94+
# module if this one is installed for instance
95+
moves._assign_picking()
96+
# Unblock release
97+
moves.action_unblock_release()
98+
# Clean up empty deliveries
99+
pickings.filtered(lambda o: not o.move_ids and not o.printed).unlink()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?xml version="1.0" encoding="utf-8" ?>
2+
<!-- Copyright 2024 Camptocamp SA
3+
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
4+
<odoo>
5+
6+
<record id="unblock_release_view_form" model="ir.ui.view">
7+
<field name="name">unblock.release.form</field>
8+
<field name="model">unblock.release</field>
9+
<field name="arch" type="xml">
10+
<form>
11+
<sheet>
12+
<group>
13+
<field name="option" />
14+
<field
15+
name="date_deadline"
16+
attrs="{'invisible': [('option', '=', 'asap')]}"
17+
/>
18+
</group>
19+
</sheet>
20+
<footer>
21+
<button name="validate" type="object" string="Validate" class="btn-primary" />
22+
<button special="cancel" string="Cancel" class="btn-default" />
23+
</footer>
24+
</form>
25+
</field>
26+
</record>
27+
28+
</odoo>

0 commit comments

Comments
 (0)