Skip to content

Commit

Permalink
[IMP] stock_available_to_promise_release: Add unrelease action on rel…
Browse files Browse the repository at this point in the history
…eased moves
  • Loading branch information
lmignon committed Mar 30, 2023
1 parent 20d852c commit ebbcf09
Show file tree
Hide file tree
Showing 12 changed files with 319 additions and 12 deletions.
1 change: 1 addition & 0 deletions stock_available_to_promise_release/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"views/stock_route_views.xml",
"views/res_config_settings.xml",
"wizards/stock_release_views.xml",
"wizards/stock_unrelease_views.xml",
],
"installable": True,
"license": "LGPL-3",
Expand Down
127 changes: 127 additions & 0 deletions stock_available_to_promise_release/models/stock_move.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,78 @@ class StockMove(models.Model):
search="_search_release_ready",
)
need_release = fields.Boolean(index=True, copy=False)
unrelease_allowed = fields.Boolean(compute="_compute_unrelease_allowed")
zip_code = fields.Char(related="partner_id.zip", store=True)
city = fields.Char(related="partner_id.city", store=True)

@api.depends("rule_id", "rule_id.available_to_promise_defer_pull")
def _compute_unrelease_allowed(self):
for move in self:
unrelease_allowed = move._is_unreleaseable()
if unrelease_allowed:
iterator = move._get_chained_moves_iterator("move_orig_ids")
next(iterator) # skip the current move
for origin_moves in iterator:
unrelease_allowed = move._is_unrelease_allowed_on_origin_moves(
origin_moves
)
if not unrelease_allowed:
break
move.unrelease_allowed = unrelease_allowed

def _is_unreleaseable(self):
"""Check if the move can be unrelease. At this stage we only check if
the move is at the end of a chain of moves and has the caracteristics
to be unrelease. We don't check the conditions on the origin moves.
The conditions on the origin moves are checked in the method
_is_unrelease_allowed_on_origin_moves.
"""
self.ensure_one()
user_is_allowed = self.env.user.has_group("stock.group_stock_user")
return (
user_is_allowed
and not self.need_release
and self.state not in ("done", "cancel")
and self.picking_type_id.code == "outgoing"
and self.rule_id.available_to_promise_defer_pull
)

def _is_unrelease_allowed_on_origin_moves(self, origin_moves):
"""We check that the origin moves are in a state that allows the unrelease
of the current move. At this stage, a move can't be unreleased if
* a picking is already printed. (The work on the picking is planed and
we don't want to change it)
* the processing of the origin moves is partially started.
"""
self.ensure_one()
pickings = origin_moves.mapped("picking_id")
if pickings.filtered("printed"):
# The picking is printed, we can't unrelease the move
# because the processing of the origin moves is started.
return False
origin_moves = origin_moves.filtered(
lambda m: m.state not in ("done", "cancel")
)
origin_qty_todo = sum(origin_moves.mapped("product_qty"))
return (
float_compare(
self.product_qty,
origin_qty_todo,
precision_rounding=self.product_uom.rounding,
)
<= 0
)

def _check_unrelease_allowed(self):
for move in self:
if not move.unrelease_allowed:
raise UserError(
_(
"You are not allowed to unrelease this move %(move_name)s.",
move_name=move.display_name,
)
)

def _previous_promised_qty_sql_main_query(self):
return """
SELECT move.id,
Expand Down Expand Up @@ -442,3 +511,61 @@ def _get_chained_moves_iterator(self, chain_field):
while moves:
yield moves
moves = moves.mapped(chain_field)

def unrelease(self, safe_unrelease=False):
"""Unrelease unreleasavbe moves
If safe_unrelease is True, the unreleasaable moves for which the
processing has already started will be ignored
"""
moves_to_unrelease = self.filtered(lambda m: m._is_unreleaseable())
if safe_unrelease:
moves_to_unrelease = self.filtered("unrelease_allowed")
moves_to_unrelease._check_unrelease_allowed()
moves_to_unrelease.write({"need_release": True})
impacted_picking_ids = set()
for move in moves_to_unrelease:
iterator = move._get_chained_moves_iterator("move_orig_ids")
next(iterator) # skip the current move
for origin_moves in iterator:
origin_moves = origin_moves.filtered(
lambda m: m.state not in ("done", "cancel")
)
if origin_moves:
origin_moves = move._split_origins(origin_moves)
impacted_picking_ids.update(origin_moves.mapped("picking_id").ids)
# avoid to propagate cancel to the original move
origin_moves.write({"propagate_cancel": False})
origin_moves._action_cancel()
moves_to_unrelease.write({"need_release": True})
for picking, moves in itertools.groupby(
moves_to_unrelease, lambda m: m.picking_id
):
move_names = "\n".join([m.display_name for m in moves])
body = _(
"The following moves have been un-released: \n%(move_names)s",
move_names=move_names,
)
picking.message_post(body=body)

def _split_origins(self, origins):
"""Split the origins of the move according to the quantity into the
move and the quantity in the origin moves.
Return the origins for the move's quantity.
"""
self.ensure_one()
qty = self.product_qty
rounding = self.product_uom.rounding
new_origin_moves = self.env["stock.move"]
while float_compare(qty, 0, precision_rounding=rounding) > 0 and origins:
origin = fields.first(origins)
if float_compare(qty, origin.product_qty, precision_rounding=rounding) >= 0:
qty -= origin.product_qty
new_origin_moves |= origin
else:
new_move_vals = origin._split(qty)
new_origin_moves |= self.create(new_move_vals)
break
origins -= origin
return new_origin_moves
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_stock_release_wizard,access.stock.release.wizard,model_stock_release,stock.group_stock_user,1,1,1,0
access_stock_unrelease_wizard,access.stock.unrelease.wizard,model_stock_unrelease,stock.group_stock_user,1,1,1,0
1 change: 1 addition & 0 deletions stock_available_to_promise_release/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from . import test_reservation
from . import test_unrelease
15 changes: 15 additions & 0 deletions stock_available_to_promise_release/tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,18 @@ def _update_qty_in_location(cls, location, product, quantity):
"outgoing_qty",
]
)

@classmethod
def _prev_picking(cls, picking):
return picking.move_ids.move_orig_ids.picking_id

@classmethod
def _out_picking(cls, pickings):
return pickings.filtered(lambda r: r.picking_type_code == "outgoing")

@classmethod
def _deliver(cls, picking):
picking.action_assign()
for line in picking.mapped("move_ids.move_line_ids"):
line.qty_done = line.reserved_qty
picking._action_done()
12 changes: 0 additions & 12 deletions stock_available_to_promise_release/tests/test_reservation.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,6 @@


class TestAvailableToPromiseRelease(PromiseReleaseCommonCase):
def _prev_picking(self, picking):
return picking.move_ids.move_orig_ids.picking_id

def _out_picking(self, pickings):
return pickings.filtered(lambda r: r.picking_type_code == "outgoing")

def _deliver(self, picking):
picking.action_assign()
for line in picking.mapped("move_ids.move_line_ids"):
line.qty_done = line.reserved_qty
picking._action_done()

def test_horizon_date(self):
move = self.env["stock.move"].create(
{
Expand Down
84 changes: 84 additions & 0 deletions stock_available_to_promise_release/tests/test_unrelease.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Copyright 2022 ACSONE SA/NV (https://www.acsone.eu)
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).

from contextlib import contextmanager
from datetime import datetime

from odoo.exceptions import UserError

from .common import PromiseReleaseCommonCase


class TestAvailableToPromiseRelease(PromiseReleaseCommonCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.shipping = cls._out_picking(
cls._create_picking_chain(
cls.wh, [(cls.product1, 5)], date=datetime(2019, 9, 2, 16, 0)
)
)
cls._update_qty_in_location(cls.loc_bin1, cls.product1, 15.0)
cls.wh.delivery_route_id.write(
{
"available_to_promise_defer_pull": True,
"no_backorder_at_release": True,
}
)

cls.shipping.release_available_to_promise()
cls.picking = cls._prev_picking(cls.shipping)
cls.picking.action_assign()

@contextmanager
def _assert_full_unreleased(self):
self.assertRecordValues(
self.shipping.move_ids,
[
{
"state": "waiting",
"need_release": False,
"unrelease_allowed": True,
}
],
)
yield
self.assertRecordValues(
self.shipping.move_ids,
[
{
"state": "waiting",
"need_release": True,
"unrelease_allowed": False,
}
],
)
self.assertEqual(self.picking.move_ids.state, "cancel")
self.assertEqual(self.picking.state, "cancel")

def test_unrelease_full(self):
"""Unrelease all moves of a released ship. The pick should be deleted and
the moves should be mark as to release"""
with self._assert_full_unreleased():
self.shipping.move_ids.unrelease()

# I can release again the move and a new pick is created
self.shipping.release_available_to_promise()
new_picking = self._prev_picking(self.shipping) - self.picking
self.assertTrue(new_picking)
self.assertEqual(new_picking.state, "assigned")

def test_unrelease_partially_processed_move(self):
"""Check it's not possible to unrelease a move that has been partially
processed"""
line = self.picking.move_ids.move_line_ids
line.qty_done = line.reserved_qty - 1
self.picking.with_context(
skip_immediate=True, skip_backorder=True
).button_validate()
self.assertEqual(self.picking.state, "done")
self.assertFalse(self.shipping.move_ids.unrelease_allowed)
with self.assertRaisesRegex(
UserError, "You are not allowed to unrelease this move"
):
self.shipping.move_ids.unrelease()
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,22 @@
</xpath>
</field>
</record>

<record id="stock_picking_type_form" model="ir.ui.view">
<field name="name">stock.picking.type.kanban</field>
<field name="model">stock.picking.type</field>
<field name="inherit_id" ref="stock.view_picking_type_form" />
<field name="arch" type="xml">
<group name="locations" position="after">
<group
name="release"
string="Chained moves release process"
attrs='{"invisible": [("code", "!=", "outgoing")]}'
>
<field name="unrelease_on_backorder" />
</group>
</group>
</field>
</record>

</odoo>
11 changes: 11 additions & 0 deletions stock_available_to_promise_release/views/stock_picking_views.xml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@
</div>
</button>
</div>
<button name="action_assign_serial" position="after">
<field name="unrelease_allowed" invisible="1" />
<button
name="unrelease"
attrs="{'invisible': [('unrelease_allowed', '=', False)]}"
string="Un Release"
type="object"
icon="fa-cube"
groups="stock.group_stock_user"
/>
</button>
</field>
</record>
<record id="view_picking_release_tree" model="ir.ui.view">
Expand Down
1 change: 1 addition & 0 deletions stock_available_to_promise_release/wizards/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from . import stock_release
from . import stock_unrelease
19 changes: 19 additions & 0 deletions stock_available_to_promise_release/wizards/stock_unrelease.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Copyright 2023 ACSONE SA/NV (https://www.acsone.eu)
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).

from odoo import models


class StockUnRelease(models.TransientModel):
_name = "stock.unrelease"
_description = "Stock Allocations Un Release"

def unrelease(self):
model = self.env.context.get("active_model")
if model not in ("stock.move", "stock.picking"):
return
records = (
self.env[model].browse(self.env.context.get("active_ids", [])).exists()
)
records.unrelease(safe_unrelease=True)
return {"type": "ir.actions.act_window_close"}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_stock_unrelease_form" model="ir.ui.view">
<field name="name">Stock Allocations Un Release</field>
<field name="model">stock.unrelease</field>
<field name="arch" type="xml">
<form string="Stock Allocations Un Release">
<p class="oe_grey">
The selected records will be un released.
</p>
<footer>
<button
name="unrelease"
string="Un Release"
type="object"
class="btn-primary"
/>
<button string="Cancel" class="btn-secondary" special="cancel" />
</footer>
</form>
</field>
</record>
<record id="action_view_stock_move_unrelease_form" model="ir.actions.act_window">
<field name="name">Un Release Move Allocations</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">stock.unrelease</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="groups_id" eval="[(4,ref('stock.group_stock_user'))]" />
<field name="binding_model_id" ref="stock.model_stock_move" />
</record>
<record id="action_view_stock_picking_unrelease_form" model="ir.actions.act_window">
<field name="name">Un Release Transfers Allocations</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">stock.unrelease</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="groups_id" eval="[(4,ref('stock.group_stock_user'))]" />
<field name="binding_model_id" ref="stock.model_stock_picking" />
</record>
</odoo>

0 comments on commit ebbcf09

Please sign in to comment.