Skip to content

Commit

Permalink
[ADD] stock_release_channel_auto_release: Add automatic release mode
Browse files Browse the repository at this point in the history
This addons define an automatic release mode on the release channels. By default
release channels manage the release of the transfers in batch mode. This means
that the transfers are released only when the user manually triggers the release
process by clicking on the release button.

When the automatic release mode is enabled, the transfers are released automatically
when a new transfer is added to the channel or as soon a product becomes available.

As for the batch mode, the automatic release process is only active on open channels.
When is locked, the automatic release process is stopped. Once the channel is unlocked,
the automatic release process is restarted and transfers into the channel are released
if they are ready to be released (IOW if quantities are available for moves not
yet released).
  • Loading branch information
lmignon authored and rousseldenis committed May 2, 2023
1 parent 1fa7123 commit 34a235b
Show file tree
Hide file tree
Showing 24 changed files with 1,017 additions and 84 deletions.
6 changes: 6 additions & 0 deletions setup/stock_release_channel_auto_release/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import setuptools

setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)
24 changes: 6 additions & 18 deletions stock_release_channel/i18n/stock_release_channel.pot
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ msgid "Assigned Total"
msgstr ""

#. module: stock_release_channel
#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__auto_release
#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__batch_mode
msgid "Auto Release"
msgstr ""

Expand Down Expand Up @@ -249,7 +249,7 @@ msgid "Full Progress"
msgstr ""

#. module: stock_release_channel
#: model:ir.model.fields.selection,name:stock_release_channel.selection__stock_release_channel__auto_release__group_commercial_partner
#: model:ir.model.fields.selection,name:stock_release_channel.selection__stock_release_channel__batch_mode__group_commercial_partner
msgid "Grouped by Commercial Partner"
msgstr ""

Expand Down Expand Up @@ -344,29 +344,17 @@ msgid "Lines]"
msgstr ""

#. module: stock_release_channel
#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_form_view
#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_kanban_view
msgid "Lock"
msgstr ""

#. module: stock_release_channel
#: model:ir.model.fields.selection,name:stock_release_channel.selection__stock_release_channel__state__locked
#: model_terms:ir.ui.view,arch_db:stock_release_channel.stock_release_channel_search_view
msgid "Locked"
msgstr ""

#. module: stock_release_channel
#: model:ir.model.fields.selection,name:stock_release_channel.selection__stock_release_channel__auto_release__max
#: model:ir.model.fields.selection,name:stock_release_channel.selection__stock_release_channel__batch_mode__max
msgid "Max"
msgstr ""

#. module: stock_release_channel
#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__max_auto_release
#: model:ir.model.fields,field_description:stock_release_channel.field_stock_release_channel__max_batch_mode
msgid "Max Transfers to release"
msgstr ""

#. module: stock_release_channel
#: model:ir.model.fields,help:stock_release_channel.field_stock_release_channel__auto_release
#: model:ir.model.fields,help:stock_release_channel.field_stock_release_channel__batch_mode
msgid ""
"Max: release N transfers to have a configured max of X deliveries in progress.\n"
"Grouped by Commercial Partner: release all transfers for acommercial partner at once."
Expand Down Expand Up @@ -688,7 +676,7 @@ msgid "Warehouse for which this channel is relevant"
msgstr ""

#. module: stock_release_channel
#: model:ir.model.fields,help:stock_release_channel.field_stock_release_channel__max_auto_release
#: model:ir.model.fields,help:stock_release_channel.field_stock_release_channel__max_batch_mode
msgid ""
"When clicking on the package icon, it releases X transfers minus on-going "
"ones not shipped (X - Waiting). This field defines X."
Expand Down
13 changes: 13 additions & 0 deletions stock_release_channel/migrations/16.0.1.0.1/pre-migrate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copyright 2022 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

import logging

from odoo.tools import sql

_logger = logging.getLogger(__name__)


def migrate(cr, version):
sql.rename_column(cr, "stock_release_channel", "auto_release", "batch_mode")
sql.rename_column(cr, "stock_release_channel", "max_auto_release", "max_batch_mode")
28 changes: 21 additions & 7 deletions stock_release_channel/models/stock_picking.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,12 @@ def assign_release_channel_on_all_need_release(self):
)
need_release._delay_assign_release_channel()

def _find_release_channel_candidate(self):
"""Find a release channel candidate for the picking.
def _find_release_channel_possible_candidate(self):
"""Find release channels possible candidate for the picking.
This method is meant to be overridden in other modules. It allows to
find a release channel candidate for the current picking based on the
picking information.
This method is meant to be inherited in other modules to add more criteria of
channel selection. It allows to find all possible channels for the current
picking(s) based on the picking information.
For example, you could define release channels based on a geographic area.
In this case, you would need to override this method to find the release
Expand All @@ -70,7 +70,21 @@ def _find_release_channel_candidate(self):
the destination as it's done into the method assign_release_channel of the
stock.release.channel model.
:return: a release channel or None
:return: release channels
"""
self.ensure_one()
return None
return self.env["stock.release.channel"].search(
self._get_release_channel_possible_candidate_domain()
)

def _get_release_channel_possible_candidate_domain(self):
self.ensure_one()
return [
("state", "!=", "asleep"),
"|",
("picking_type_ids", "=", False),
("picking_type_ids", "in", self.picking_type_id.ids),
"|",
("warehouse_id", "=", False),
("warehouse_id", "=", self.picking_type_id.warehouse_id.id),
]
84 changes: 43 additions & 41 deletions stock_release_channel/models/stock_release_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pytz import timezone

from odoo import _, api, exceptions, fields, models
from odoo.osv.expression import NEGATIVE_TERM_OPERATORS
from odoo.tools.safe_eval import (
datetime as safe_datetime,
dateutil as safe_dateutil,
Expand Down Expand Up @@ -64,8 +65,10 @@ class StockReleaseChannel(models.Model):
help="Write Python code to filter out pickings.",
)
active = fields.Boolean(default=True)

auto_release = fields.Selection(
release_mode = fields.Selection(
[("batch", "Batch (Manual)")], required=True, default="batch"
)
batch_mode = fields.Selection(
selection=[
("max", "Max"),
("group_commercial_partner", "Grouped by Commercial Partner"),
Expand All @@ -76,7 +79,7 @@ class StockReleaseChannel(models.Model):
" in progress.\nGrouped by Commercial Partner: release all transfers for a"
"commercial partner at once.",
)
max_auto_release = fields.Integer(
max_batch_mode = fields.Integer(
string="Max Transfers to release",
default=10,
help="When clicking on the package icon, it releases X transfers minus "
Expand Down Expand Up @@ -199,6 +202,7 @@ class StockReleaseChannel(models.Model):
)
is_release_allowed = fields.Boolean(
compute="_compute_is_release_allowed",
search="_search_is_release_allowed",
help="Technical field to check if the "
"action 'Release Next Batch' is allowed.",
)
Expand Down Expand Up @@ -228,6 +232,25 @@ def _compute_is_release_allowed(self):
for rec in self:
rec.is_release_allowed = rec.state == "open" and not rec.release_forbidden

@api.model
def _get_is_release_allowed_domain(self):
return [("state", "=", "open"), ("release_forbidden", "=", False)]

@api.model
def _get_is_release_not_allowed_domain(self):
return ["|", ("state", "!=", "open"), ("release_forbidden", "=", True)]

@api.model
def _search_is_release_allowed(self, operator, value):
if "in" in operator:
raise ValueError(f"Invalid operator {operator}")
negative_op = operator in NEGATIVE_TERM_OPERATORS
is_release_allowed = (value and not negative_op) or (not value and negative_op)
domain = self._get_is_release_allowed_domain()
if not is_release_allowed:
domain = self._get_is_release_not_allowed_domain()
return domain

def _get_picking_to_unassign_domain(self):
return [
("release_channel_id", "in", self.ids),
Expand Down Expand Up @@ -444,10 +467,6 @@ def _prepare_domain(self):
domain = safe_eval(self.rule_domain) or []
return domain

@api.model
def _get_assignable_release_channel_domain(self):
return [("state", "!=", "asleep")]

@api.model
def assign_release_channel(self, picking):
picking.ensure_one()
Expand All @@ -456,39 +475,22 @@ def assign_release_channel(self, picking):
"done",
):
return
# get channel candidates from the picking
channel = picking._find_release_channel_candidate()
if channel:
picking.release_channel_id = channel
return True
# No channel provided by the picking -> try to find one be evaluating the rules
# of all available channels
# do a single query rather than one for each rule*picking
for channel in self.sudo().search(
self._get_assignable_release_channel_domain()
):
if (
channel.picking_type_ids
and picking.picking_type_id not in channel.picking_type_ids
):
continue

for channel in picking._find_release_channel_possible_candidate():
current = picking
domain = channel._prepare_domain()

if not domain and not channel.code:
current.release_channel_id = channel
if domain:
current = picking.filtered_domain(domain)
else:
current = picking

if not current:
continue

if channel.code:
current = channel._eval_code(current)

if not current:
continue
current = channel._assign_release_channel_additional_filter(current)
if not current:
continue
current.release_channel_id = channel
break

Expand Down Expand Up @@ -661,43 +663,43 @@ def _pickings_sort_key(picking):
return (-int(picking.priority or 1), picking.date_priority, picking.id)

def _get_next_pickings(self):
return getattr(self, "_get_next_pickings_{}".format(self.auto_release))()
return getattr(self, "_get_next_pickings_{}".format(self.batch_mode))()

def _get_pickings_to_release(self):
"""Get the pickings to release."""
domain = self._field_picking_domains()["count_picking_release_ready"]
domain += [("release_channel_id", "in", self.ids)]
return self.env["stock.picking"].search(domain)

def _get_next_pickings_max(self):
if not self.max_auto_release:
if not self.max_batch_mode:
raise exceptions.UserError(_("No Max transfers to release is configured."))

waiting_domain = self._field_picking_domains()["count_picking_waiting"]
waiting_domain += [("release_channel_id", "=", self.id)]
released_in_progress = self.env["stock.picking"].search_count(waiting_domain)

release_limit = max(self.max_auto_release - released_in_progress, 0)
release_limit = max(self.max_batch_mode - released_in_progress, 0)
if not release_limit:
raise exceptions.UserError(
_(
"The number of released transfers in"
" progress is already at the maximum."
)
)
domain = self._field_picking_domains()["count_picking_release_ready"]
domain += [("release_channel_id", "=", self.id)]
next_pickings = self.env["stock.picking"].search(domain)
next_pickings = self._get_pickings_to_release()
# We have to use a python sort and not a order + limit on the search
# because "date_priority" is computed and not stored. If needed, we
# should evaluate making it a stored field in the module
# "stock_available_to_promise_release".
return next_pickings.sorted(self._pickings_sort_key)[:release_limit]

def _get_next_pickings_group_commercial_partner(self):
domain = self._field_picking_domains()["count_picking_release_ready"]
domain += [("release_channel_id", "=", self.id)]
# We have to use a python sort and not a order + limit on the search
# because "date_priority" is computed and not stored. If needed, we
# should evaluate making it a stored field in the module
# "stock_available_to_promise_release".
next_pickings = (
self.env["stock.picking"].search(domain).sorted(self._pickings_sort_key)
)
next_pickings = self._get_pickings_to_release().sorted(self._pickings_sort_key)
if not next_pickings:
return self.env["stock.picking"].browse()
first_picking = next_pickings[0]
Expand Down
17 changes: 17 additions & 0 deletions stock_release_channel/tests/common.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Copyright 2020 Camptocamp (https://www.camptocamp.com)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)

import logging

from odoo import fields
from odoo.tests import common

Expand All @@ -20,6 +22,21 @@ def setUpClass(cls):
)
cls._create_base_data()

def setUp(self):
super(ReleaseChannelCase, self).setUp()
loggers = ["odoo.addons.stock_release_channel.models.stock_release_channel"]
for logger in loggers:
logging.getLogger(logger).addFilter(self)

# pylint: disable=unused-variable
@self.addCleanup
def un_mute_logger():
for logger_ in loggers:
logging.getLogger(logger_).removeFilter(self)

def filter(self, record):
return 0

@classmethod
def _create_base_data(cls):
cls.wh = cls.env["stock.warehouse"].create(
Expand Down
8 changes: 4 additions & 4 deletions stock_release_channel/tests/test_channel_release_batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ def test_release_auto_forbidden(self):
self.channel.release_next_batch()

def test_release_auto_max_next_batch_no_config(self):
self.channel.max_auto_release = 0
self.channel.max_batch_mode = 0
with self.assertRaises(exceptions.UserError):
self.channel.release_next_batch()

def test_release_auto_max_next_batch(self):
self.channel.max_auto_release = 2
self.channel.max_batch_mode = 2
self.channel.release_next_batch()
# 2 have been released
self.assertEqual(
Expand All @@ -60,15 +60,15 @@ def test_release_auto_max_no_next_batch(self):
self._assert_action_nothing_in_the_queue(action)

def test_release_auto_group_commercial_partner(self):
self.channel.auto_release = "group_commercial_partner"
self.channel.batch_mode = "group_commercial_partner"
self.channel.release_next_batch()
self.assertFalse(self.picking.need_release)
self.assertFalse(self.picking2.need_release)
other_pickings = self.pickings - (self.picking | self.picking2)
self.assertTrue(all(p.need_release) for p in other_pickings)

def test_release_auto_group_commercial_partner_no_next_batch(self):
self.channel.auto_release = "group_commercial_partner"
self.channel.batch_mode = "group_commercial_partner"
self.pickings.need_release = False # cheat for getting the right condition
action = self.channel.release_next_batch()
self._assert_action_nothing_in_the_queue(action)
Expand Down
Loading

0 comments on commit 34a235b

Please sign in to comment.