diff --git a/setup/stock_release_channel_auto_release/odoo/addons/stock_release_channel_auto_release b/setup/stock_release_channel_auto_release/odoo/addons/stock_release_channel_auto_release new file mode 120000 index 0000000000..9e2130c24e --- /dev/null +++ b/setup/stock_release_channel_auto_release/odoo/addons/stock_release_channel_auto_release @@ -0,0 +1 @@ +../../../../stock_release_channel_auto_release \ No newline at end of file diff --git a/setup/stock_release_channel_auto_release/setup.py b/setup/stock_release_channel_auto_release/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/stock_release_channel_auto_release/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_release_channel/i18n/stock_release_channel.pot b/stock_release_channel/i18n/stock_release_channel.pot index 475800b16c..44e429b86b 100644 --- a/stock_release_channel/i18n/stock_release_channel.pot +++ b/stock_release_channel/i18n/stock_release_channel.pot @@ -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 "" @@ -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 "" @@ -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." @@ -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." diff --git a/stock_release_channel/migrations/16.0.1.0.1/pre-migrate.py b/stock_release_channel/migrations/16.0.1.0.1/pre-migrate.py new file mode 100644 index 0000000000..e370dceb5d --- /dev/null +++ b/stock_release_channel/migrations/16.0.1.0.1/pre-migrate.py @@ -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") diff --git a/stock_release_channel/models/stock_picking.py b/stock_release_channel/models/stock_picking.py index 1968693689..c5828f30f0 100644 --- a/stock_release_channel/models/stock_picking.py +++ b/stock_release_channel/models/stock_picking.py @@ -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 @@ -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), + ] diff --git a/stock_release_channel/models/stock_release_channel.py b/stock_release_channel/models/stock_release_channel.py index 6c934d1d6d..bf3a82f4d8 100644 --- a/stock_release_channel/models/stock_release_channel.py +++ b/stock_release_channel/models/stock_release_channel.py @@ -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, @@ -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"), @@ -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 " @@ -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.", ) @@ -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), @@ -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() @@ -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 @@ -661,17 +663,23 @@ 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( _( @@ -679,9 +687,7 @@ def _get_next_pickings_max(self): " 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 @@ -689,15 +695,11 @@ def _get_next_pickings_max(self): 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] diff --git a/stock_release_channel/tests/common.py b/stock_release_channel/tests/common.py index 0a996d80d3..3bc9fd5d3c 100644 --- a/stock_release_channel/tests/common.py +++ b/stock_release_channel/tests/common.py @@ -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 @@ -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( diff --git a/stock_release_channel/tests/test_channel_release_batch.py b/stock_release_channel/tests/test_channel_release_batch.py index 9caef113f3..44101a2277 100644 --- a/stock_release_channel/tests/test_channel_release_batch.py +++ b/stock_release_channel/tests/test_channel_release_batch.py @@ -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( @@ -60,7 +60,7 @@ 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) @@ -68,7 +68,7 @@ def test_release_auto_group_commercial_partner(self): 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) diff --git a/stock_release_channel/views/stock_release_channel_views.xml b/stock_release_channel/views/stock_release_channel_views.xml index 8ef1d2bc78..84d64fbf92 100644 --- a/stock_release_channel/views/stock_release_channel_views.xml +++ b/stock_release_channel/views/stock_release_channel_views.xml @@ -71,35 +71,42 @@ name="options" groups="stock.group_stock_manager" > - + - - - - - - + + + + + + + - - - + @@ -150,7 +157,15 @@ + + @@ -176,6 +191,7 @@ class="oe_background_grey o_kanban_dashboard o_emphasize_colors o_stock_release_channel" create="0" > + @@ -197,6 +213,8 @@ + +
Operations > Release Channels to access to the dashboard. + +Edit a channel and select 'Automatic' into the list of available release mode. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* ACSONE SA/NV + +Contributors +~~~~~~~~~~~~ + +* Laurent Mignon +* Jacques-Etienne Baudoux + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/wms `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/stock_release_channel_auto_release/__init__.py b/stock_release_channel_auto_release/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/stock_release_channel_auto_release/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_release_channel_auto_release/__manifest__.py b/stock_release_channel_auto_release/__manifest__.py new file mode 100644 index 0000000000..59d41a2fb3 --- /dev/null +++ b/stock_release_channel_auto_release/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2022 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Stock Release Channel Auto Release", + "summary": """ + Add an automatic release mode to the release channel""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/wms", + "depends": [ + "stock_release_channel", + "stock_move_auto_assign_auto_release", + ], + "data": [ + "data/queue_job_data.xml", + "views/stock_release_channel.xml", + ], + "demo": [], +} diff --git a/stock_release_channel_auto_release/data/queue_job_data.xml b/stock_release_channel_auto_release/data/queue_job_data.xml new file mode 100644 index 0000000000..248d47bb07 --- /dev/null +++ b/stock_release_channel_auto_release/data/queue_job_data.xml @@ -0,0 +1,15 @@ + + + + + stock_release_channel + + + + + + auto_release + + + + diff --git a/stock_release_channel_auto_release/models/__init__.py b/stock_release_channel_auto_release/models/__init__.py new file mode 100644 index 0000000000..ebbc07de25 --- /dev/null +++ b/stock_release_channel_auto_release/models/__init__.py @@ -0,0 +1,2 @@ +from . import stock_release_channel +from . import stock_picking diff --git a/stock_release_channel_auto_release/models/stock_picking.py b/stock_release_channel_auto_release/models/stock_picking.py new file mode 100644 index 0000000000..2aa7fa9e22 --- /dev/null +++ b/stock_release_channel_auto_release/models/stock_picking.py @@ -0,0 +1,49 @@ +# Copyright 2022 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, models +from odoo.osv.expression import AND + +from odoo.addons.queue_job.job import identity_exact + + +class StockPicking(models.Model): + + _inherit = "stock.picking" + + @api.model + def _is_auto_release_allowed_depends(self): + depends = super()._is_auto_release_allowed_depends() + depends.append("release_channel_id.is_auto_release_allowed") + return depends + + @property + def _is_auto_release_allowed_domain(self): + domain = super()._is_auto_release_allowed_domain + return AND( + [ + domain, + [("release_channel_id.is_auto_release_allowed", "=", True)], + ] + ) + + def _delay_auto_release_available_to_promise(self): + for picking in self: + picking.with_delay( + identity_key=identity_exact, + description=_( + "Auto release available to promise %(name)s", name=picking.name + ), + ).auto_release_available_to_promise() + + def auto_release_available_to_promise(self): + to_release = self.filtered("is_auto_release_allowed") + to_release.release_available_to_promise() + return to_release + + def assign_release_channel(self): + res = super().assign_release_channel() + self.filtered( + "is_auto_release_allowed" + )._delay_auto_release_available_to_promise() + return res diff --git a/stock_release_channel_auto_release/models/stock_release_channel.py b/stock_release_channel_auto_release/models/stock_release_channel.py new file mode 100644 index 0000000000..19458212b8 --- /dev/null +++ b/stock_release_channel_auto_release/models/stock_release_channel.py @@ -0,0 +1,73 @@ +# Copyright 2022 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.osv.expression import AND, NEGATIVE_TERM_OPERATORS, OR + + +class StockReleaseChannel(models.Model): + + _inherit = "stock.release.channel" + + release_mode = fields.Selection( + selection_add=[("auto", "Automatic")], + ondelete={"auto": "set default"}, + ) + + is_auto_release_allowed = fields.Boolean( + compute="_compute_is_auto_release_allowed", + search="_search_is_auto_release_allowed", + ) + + @api.depends("release_mode", "is_release_allowed") + def _compute_is_auto_release_allowed(self): + for channel in self: + channel.is_auto_release_allowed = ( + channel.release_mode == "auto" and channel.is_release_allowed + ) + + @api.model + def _get_is_auto_release_allowed_domain(self): + return AND( + [self._get_is_release_allowed_domain(), [("release_mode", "=", "auto")]] + ) + + @api.model + def _get_is_auto_release_not_allowed_domain(self): + return OR( + [ + self._get_is_release_not_allowed_domain(), + [("release_mode", "!=", "auto")], + ] + ) + + @api.model + def _search_is_auto_release_allowed(self, operator, value): + if "in" in operator: + raise ValueError(f"Invalid operator {operator}") + negative_op = operator in NEGATIVE_TERM_OPERATORS + is_auto_release_allowed = (value and not negative_op) or ( + not value and negative_op + ) + domain = self._get_is_auto_release_allowed_domain() + if not is_auto_release_allowed: + domain = self._get_is_auto_release_not_allowed_domain() + return domain + + def write(self, vals): + res = super().write(vals) + release_mode = vals.get("release_mode") + if release_mode == "auto": + self.invalidate_recordset(["is_auto_release_allowed"]) + self.auto_release_all() + return res + + def action_unlock(self): + res = super().action_unlock() + if not self.env.context.get("no_auto_release"): + self.auto_release_all() + return res + + def auto_release_all(self): + pickings = self.filtered("is_auto_release_allowed")._get_pickings_to_release() + pickings._delay_auto_release_available_to_promise() diff --git a/stock_release_channel_auto_release/readme/CONTRIBUTORS.rst b/stock_release_channel_auto_release/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..ffd2426095 --- /dev/null +++ b/stock_release_channel_auto_release/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Laurent Mignon +* Jacques-Etienne Baudoux diff --git a/stock_release_channel_auto_release/readme/DESCRIPTION.rst b/stock_release_channel_auto_release/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..0ef1e9001a --- /dev/null +++ b/stock_release_channel_auto_release/readme/DESCRIPTION.rst @@ -0,0 +1,13 @@ +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). diff --git a/stock_release_channel_auto_release/readme/USAGE.rst b/stock_release_channel_auto_release/readme/USAGE.rst new file mode 100644 index 0000000000..9228bb484d --- /dev/null +++ b/stock_release_channel_auto_release/readme/USAGE.rst @@ -0,0 +1,3 @@ +Use Inventory > Operations > Release Channels to access to the dashboard. + +Edit a channel and select 'Automatic' into the list of available release mode. diff --git a/stock_release_channel_auto_release/static/description/icon.png b/stock_release_channel_auto_release/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/stock_release_channel_auto_release/static/description/icon.png differ diff --git a/stock_release_channel_auto_release/static/description/index.html b/stock_release_channel_auto_release/static/description/index.html new file mode 100644 index 0000000000..ad46444dd9 --- /dev/null +++ b/stock_release_channel_auto_release/static/description/index.html @@ -0,0 +1,436 @@ + + + + + + +Stock Release Channel Auto Release + + + +
+

Stock Release Channel Auto Release

+ + +

Beta License: AGPL-3 OCA/wms Translate me on Weblate Try me on Runbot

+

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).

+

Table of contents

+ +
+

Usage

+

Use Inventory > Operations > Release Channels to access to the dashboard.

+

Edit a channel and select ‘Automatic’ into the list of available release mode.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/wms project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/stock_release_channel_auto_release/tests/__init__.py b/stock_release_channel_auto_release/tests/__init__.py new file mode 100644 index 0000000000..a42cf52214 --- /dev/null +++ b/stock_release_channel_auto_release/tests/__init__.py @@ -0,0 +1 @@ +from . import test_channel_release_auto diff --git a/stock_release_channel_auto_release/tests/test_channel_release_auto.py b/stock_release_channel_auto_release/tests/test_channel_release_auto.py new file mode 100644 index 0000000000..73e7e0009c --- /dev/null +++ b/stock_release_channel_auto_release/tests/test_channel_release_auto.py @@ -0,0 +1,134 @@ +# Copyright 2022 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from contextlib import contextmanager + +from odoo.addons.queue_job.job import identity_exact +from odoo.addons.queue_job.tests.common import trap_jobs +from odoo.addons.stock_release_channel.tests.common import ChannelReleaseCase + + +class TestChannelReleaseAuto(ChannelReleaseCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.channel.release_mode = "auto" + + cls._update_qty_in_location(cls.loc_bin1, cls.product1, 1000.0) + cls._update_qty_in_location(cls.loc_bin1, cls.product2, 1000.0) + + # invalidate cache for computed fields bases on qty in stock + cls.env.invalidate_all() + + @contextmanager + def assert_release_job_enqueued(self, channel): + pickings_to_release = channel._get_pickings_to_release() + self.assertTrue(pickings_to_release) + with trap_jobs() as trap: + yield + trap.assert_jobs_count(len(pickings_to_release)) + for pick in pickings_to_release: + trap.assert_enqueued_job( + pick.auto_release_available_to_promise, + args=(), + kwargs={}, + properties=dict( + identity_key=identity_exact, + ), + ) + + def test_channel_auto_release_forbidden(self): + self.assertTrue(self.channel.is_auto_release_allowed) + self.channel.release_forbidden = True + self.assertFalse(self.channel.is_auto_release_allowed) + + def test_channel_search_is_auto_release_allowed(self): + self.assertTrue(self.channel.is_auto_release_allowed) + self.assertIn( + self.channel, + self.env["stock.release.channel"].search( + [("is_auto_release_allowed", "=", True)] + ), + ) + self.assertNotIn( + self.channel, + self.env["stock.release.channel"].search( + [("is_auto_release_allowed", "=", False)] + ), + ) + self.channel.release_forbidden = True + self.assertFalse(self.channel.is_auto_release_allowed) + self.assertNotIn( + self.channel, + self.env["stock.release.channel"].search( + [("is_auto_release_allowed", "=", True)] + ), + ) + self.assertIn( + self.channel, + self.env["stock.release.channel"].search( + [("is_auto_release_allowed", "=", False)] + ), + ) + + def test_picking_auto_release_forbidden(self): + self.assertTrue(self.picking.is_auto_release_allowed) + self.channel.release_forbidden = True + self.assertFalse(self.picking.is_auto_release_allowed) + + def test_picking_search_is_auto_release_allowed(self): + self.assertTrue(self.picking.is_auto_release_allowed) + self.assertIn( + self.picking, + self.env["stock.picking"].search([("is_auto_release_allowed", "=", True)]), + ) + self.assertNotIn( + self.picking, + self.env["stock.picking"].search([("is_auto_release_allowed", "=", False)]), + ) + self.channel.release_forbidden = True + self.assertFalse(self.picking.is_auto_release_allowed) + self.assertNotIn( + self.picking, + self.env["stock.picking"].search([("is_auto_release_allowed", "=", True)]), + ) + self.assertIn( + self.picking, + self.env["stock.picking"].search([("is_auto_release_allowed", "=", False)]), + ) + + def test_channel_auto_release_all_launch_release_job(self): + with self.assert_release_job_enqueued(self.channel): + self.channel.auto_release_all() + + def test_picking_assign_release_channel_launch_release_job(self): + self.picking.release_channel_id = None + self.channel.release_mode = "batch" + with trap_jobs() as trap: + self.picking.assign_release_channel() + self.assertEqual(self.picking.release_channel_id, self.channel) + trap.assert_jobs_count(0) + self.picking.release_channel_id = None + self.channel.release_mode = "auto" + with trap_jobs() as trap: + self.picking.assign_release_channel() + self.assertEqual(self.picking.release_channel_id, self.channel) + trap.assert_jobs_count(1) + trap.assert_enqueued_job( + self.picking.auto_release_available_to_promise, + args=(), + kwargs={}, + properties=dict( + identity_key=identity_exact, + ), + ) + + def test_release_channel_change_mode_launch_release_job(self): + self.channel.release_mode = "batch" + with self.assert_release_job_enqueued(self.channel): + self.channel.release_mode = "auto" + + def test_release_channel_action_unlock_launch_release_job(self): + self.channel.action_lock() + with self.assert_release_job_enqueued(self.channel): + self.channel.action_unlock() diff --git a/stock_release_channel_auto_release/views/stock_release_channel.xml b/stock_release_channel_auto_release/views/stock_release_channel.xml new file mode 100644 index 0000000000..3a5834fd29 --- /dev/null +++ b/stock_release_channel_auto_release/views/stock_release_channel.xml @@ -0,0 +1,31 @@ + + + + + stock.release.channel.kanban (in stock_release_channel_auto_release) + stock.release.channel + + + + + +