Skip to content

Commit

Permalink
sale_stock_available_to_promise_release_block: add Unblock Release wi…
Browse files Browse the repository at this point in the history
…zard

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).
  • Loading branch information
sebalix committed May 27, 2024
1 parent 1976a48 commit 0b0213d
Show file tree
Hide file tree
Showing 12 changed files with 322 additions and 9 deletions.
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
Expand Up @@ -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",
Expand All @@ -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
Expand Up @@ -14,3 +14,10 @@ class SaleOrder(models.Model):
states={"draft": [("readonly", False)]},
help="Block the release of the generated delivery at order confirmation.",
)

def action_open_move_need_release(self):
action = super().action_open_move_need_release()

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

View check run for this annotation

Codecov / codecov/patch

sale_stock_available_to_promise_release_block/models/sale_order.py#L19

Added line #L19 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 23 in sale_stock_available_to_promise_release_block/models/sale_order.py

View check run for this annotation

Codecov / codecov/patch

sale_stock_available_to_promise_release_block/models/sale_order.py#L21-L23

Added lines #L21 - L23 were not covered by tests
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,21 @@
# Copyright 2024 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)

import psycopg2

from odoo import 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

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)
Expand All @@ -16,3 +27,92 @@ 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, order_lines, date_deadline=None, from_order=None, option="free"
):
wiz_form = Form(
self.env["unblock.release"].with_context(
from_sale_order_id=from_order and from_order.id,
active_model=order_lines._name,
active_ids=order_lines.ids,
default_option=option,
)
)
if date_deadline:
wiz_form.date_deadline = date_deadline
return wiz_form.save()

def test_sale_order_line_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()
# Unblock deliveries through the wizard, opened from another SO
# to define default values
new_sale = self._create_sale_order()
new_sale.commitment_date = fields.Datetime.add(fields.Datetime.now(), days=1)
wiz = self._create_unblock_release_wizard(
self.sale.order_line, from_order=new_sale
)
self.assertEqual(wiz.option, "contextual")
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 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_sale.commitment_date)
self.assertNotEqual(old_picking, new_picking)
self.assertFalse(old_picking.exists())

def test_sale_order_line_unblock_release_free(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.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_sale_order_line_unblock_release_asap(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
today = fields.Datetime.now()
wiz = self._create_unblock_release_wizard(self.sale.order_line, option="asap")
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_sale_order_line_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
new_sale = self._create_sale_order()
yesterday = fields.Datetime.subtract(fields.Datetime.now(), days=1)
with self.assertRaises(psycopg2.errors.CheckViolation):
self._create_unblock_release_wizard(
self.sale.order_line, date_deadline=yesterday, from_order=new_sale
)
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down
16 changes: 16 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,16 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2024 Camptocamp SA
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>

<!-- 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,102 @@
# Copyright 2024 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)

from odoo import api, 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",
)
move_ids = fields.Many2many(
comodel_name="stock.move",
string="Delivery moves",
)
option = fields.Selection(
selection=lambda self: self._selection_option(),
default="asap",
required=True,
)
date_deadline = fields.Datetime(
compute="_compute_date_deadline", store=True, readonly=False, required=True
)

_sql_constraints = [
(
"check_scheduled_date",
"CHECK (date_deadline::date >= now()::date)",
"You cannot reschedule deliveries in the past.",
),
]

def _selection_option(self):
options = [
("free", "Free"),
("asap", "As soon as possible"),
]
if self.env.context.get("from_sale_order_id"):
options.append(("contextual", "Contextual"))
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 == "asap":
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_id = self.env.context.get("from_sale_order_id")
from_sale_order = self.env["sale.order"].browse(from_sale_order_id).exists()
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)]

Check warning on line 66 in sale_stock_available_to_promise_release_block/wizards/unblock_release.py

View check run for this annotation

Codecov / codecov/patch

sale_stock_available_to_promise_release_block/wizards/unblock_release.py#L66

Added line #L66 was not covered by tests
if from_sale_order:
res["option"] = "contextual"
return res

def validate(self):
self.ensure_one()
move_states = (
"draft",
"waiting",
"confirmed",
"partially_available",
"assigned",
)
moves = (self.order_line_ids.move_ids or self.move_ids).filtered_domain(
[("state", "in", move_states), ("release_blocked", "=", True)]
)
# Unset current deliveries (keep track of them to delete empty ones at the end)
pickings = moves.picking_id
moves.picking_id = False
# Update the scheduled date
date_deadline = (
fields.Datetime.now() if self.option == "asap" else self.date_deadline
)
date_planned = fields.Datetime.subtract(
date_deadline, days=self.env.company.security_lead
)
moves.date = date_planned
# Re-assign deliveries: moves sharing the same criteria - like date - will
# be part of the same delivery.
# NOTE: this will also leverage stock_picking_group_by_partner_by_carrier
# module if this one is installed for instance
moves._assign_picking()
# Unblock release
moves.action_unblock_release()
# 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,28 @@
<?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="date_deadline"
attrs="{'invisible': [('option', '=', 'asap')]}"
/>
</group>
</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>

0 comments on commit 0b0213d

Please sign in to comment.