diff --git a/shopfloor/migrations/14.0.5.0.0/post-migration.py b/shopfloor/migrations/14.0.5.0.0/post-migration.py new file mode 100644 index 0000000000..b2fa9f97ca --- /dev/null +++ b/shopfloor/migrations/14.0.5.0.0/post-migration.py @@ -0,0 +1,15 @@ +# Copyright 2024 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +import json + +from openupgradelib import openupgrade + + +@openupgrade.migrate() +def migrate(env, version): + scenario = env.ref("shopfloor.scenario_cluster_picking") + options = scenario.options + options["allow_move_line_processing_sort_order"] = True + scenario.write( + {"options_edit": json.dumps(options or {}, indent=4, sort_keys=True)} + ) diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index 7521a67db4..9e5f6a6347 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -1,6 +1,7 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import _, api, exceptions, fields, models +from odoo.tools.safe_eval import test_python_expr PICK_PACK_SAME_TIME_HELP = """ If you tick this box, while picking goods from a location @@ -53,6 +54,30 @@ to scan a destination package. """ +MOVE_LINE_PROCESSING_SORT_ORDER_HELP = ( + "By priority & location:\n" + "> each line is sorted by priority and then by location to perform a smart path in" + " the warehouse.\n" + "By location grouped by product:\n" + "> in case of multiple move lines for the same product, break the sorting to" + " finalize a started product by processing all other move lines for that product" + " in order to group a product on a picking device. When stacking products on a" + " pallet, this prevents to spread a same product at different level on the stack." +) + +CUSTOM_CODE_DEFAULT = ( + "# Available variables:\n" + "# - env: Odoo Environment on which the action is triggered\n" + "# - time, datetime, dateutil, timezone: useful Python libraries\n" + "# - records: lines to sort\n" + "# - default_filter_func: default filtering function\n" + "#\n" + "# To provide the order for stock.move.line records to be processed, define eg.:\n" + "# move_lines = records.filtered(default_filter_func).sorted()\n" + "\n" + "\n" +) + class ShopfloorMenu(models.Model): _inherit = "shopfloor.menu" @@ -226,6 +251,26 @@ class ShopfloorMenu(models.Model): allow_alternative_destination_package_is_possible = fields.Boolean( compute="_compute_allow_alternative_destination_package_is_possible" ) + move_line_processing_sort_order_is_possible = fields.Boolean( + compute="_compute_move_line_processing_sort_order_is_possible" + ) + move_line_processing_sort_order = fields.Selection( + selection=[ + ("location", "Location"), + ("location_grouped_product", "Location Grouping products"), + ("custom_code", "Custom code"), + ], + string="Sort method used when processing move lines", + default="location", + required=True, + help=MOVE_LINE_PROCESSING_SORT_ORDER_HELP, + ) + + move_line_processing_sort_order_custom_code = fields.Text( + string="Custom sort key code", + default=CUSTOM_CODE_DEFAULT, + help="Python code to sort move lines.", + ) @api.onchange("unload_package_at_destination") def _onchange_unload_package_at_destination(self): @@ -455,3 +500,20 @@ def _compute_allow_alternative_destination_package_is_possible(self): menu.allow_alternative_destination_package_is_possible = ( menu.scenario_id.has_option("allow_alternative_destination_package") ) + + @api.depends("scenario_id") + def _compute_move_line_processing_sort_order_is_possible(self): + for menu in self: + menu.move_line_processing_sort_order_is_possible = ( + menu.scenario_id.has_option("allow_move_line_processing_sort_order") + ) + + @api.constrains("move_line_processing_sort_order_custom_code") + def _check_python_code(self): + for menu in self.sudo().filtered("move_line_processing_sort_order_custom_code"): + msg = test_python_expr( + expr=menu.move_line_processing_sort_order_custom_code.strip(), + mode="exec", + ) + if msg: + raise exceptions.ValidationError(msg) diff --git a/shopfloor/services/cluster_picking.py b/shopfloor/services/cluster_picking.py index 20d161a4af..9d3fca447e 100644 --- a/shopfloor/services/cluster_picking.py +++ b/shopfloor/services/cluster_picking.py @@ -2,8 +2,16 @@ # Copyright 2020-2022 Jacques-Etienne Baudoux (BCIM) # Copyright 2023 Michael Tietz (MT Software) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo import _, fields +from pytz import timezone + +from odoo import _, exceptions, fields from odoo.osv import expression +from odoo.tools.safe_eval import ( + datetime as safe_datetime, + dateutil as safe_dateutil, + safe_eval, + time as safe_time, +) from odoo.addons.base_rest.components.service import to_bool, to_int from odoo.addons.component.core import Component @@ -362,17 +370,80 @@ def _lines_for_picking_batch(self, picking_batch, filter_func=lambda x: x): # after ALL the other lines in the batch are processed. return lines.sorted(key=self._sort_key_lines) + @staticmethod + def _lines_filtering(line): + return ( + line.state in ("assigned", "partially_available") + # On 'StockPicking.action_assign()', result_package_id is set to + # the same package as 'package_id'. Here, we need to exclude lines + # that were already put into a bin, i.e. the destination package + # is different. + and ( + not line.result_package_id or line.result_package_id == line.package_id + ) + ) + + def _group_by_product(self, lines): + grouped_line_ids = [] + product_ids_checked = set() + for line in lines: + if line.product_id.id not in product_ids_checked: + same_product_line_ids = lines.filtered( + lambda x: x.product_id == line.product_id + ).ids + grouped_line_ids.extend(same_product_line_ids) + product_ids_checked.add(line.product_id.id) + lines = self.env["stock.move.line"].browse(grouped_line_ids) + return lines + def _lines_to_pick(self, picking_batch): - return self._lines_for_picking_batch( - picking_batch, - filter_func=lambda l: ( - l.state in ("assigned", "partially_available") - # On 'StockPicking.action_assign()', result_package_id is set to - # the same package as 'package_id'. Here, we need to exclude lines - # that were already put into a bin, i.e. the destination package - # is different. - and (not l.result_package_id or l.result_package_id == l.package_id) - ), + order = self.work.menu.move_line_processing_sort_order + if order == "location": + lines = self._lines_for_picking_batch( + picking_batch, filter_func=self._lines_filtering + ) + elif order == "location_grouped_product": + # we need to call _lines_for_picking_batch + # without passing a filter_func so that the ordering is computed + # taking into account all lines in the batch, + # including those that have already been processed. + lines = self._lines_for_picking_batch( + picking_batch, + filter_func=lambda x: x, + ) + lines = self._group_by_product(lines).filtered(self._lines_filtering) + elif order == "custom_code": + lines = self._eval_custom_code(picking_batch) + return lines + + def _eval_context(self, move_lines): + return { + "uid": self.env.uid, + "user": self.env.user, + "time": safe_time, + "datetime": safe_datetime, + "dateutil": safe_dateutil, + "timezone": timezone, + # orm + "env": self.env, + # record + "records": move_lines, + # filter + "default_filter_func": self._lines_filtering, + } + + def _eval_custom_code(self, picking_batch): + expr = self.work.menu.move_line_processing_sort_order_custom_code + move_lines = picking_batch.mapped("picking_ids.move_line_ids") + eval_context = self._eval_context(move_lines) + try: + safe_eval(expr, eval_context, mode="exec", nocopy=True) + except Exception as err: + raise exceptions.UserError( + _("Error when evaluating the move lines sorting code:\n %s") % (err) + ) + return eval_context.get( + "move_lines", move_lines.filtered(self._lines_filtering) ) def _last_picked_line(self, picking): diff --git a/shopfloor/tests/test_cluster_picking_sort_order.py b/shopfloor/tests/test_cluster_picking_sort_order.py new file mode 100644 index 0000000000..6c972e14b6 --- /dev/null +++ b/shopfloor/tests/test_cluster_picking_sort_order.py @@ -0,0 +1,100 @@ +# Copyright 2023 Camptocamp SA (https://www.camptocamp.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields +from odoo.tests import tagged + +from .test_cluster_picking_base import ClusterPickingCommonCase + + +@tagged("post_install", "-at_install") +class ClusterPickingSortOrder(ClusterPickingCommonCase): + @classmethod + def setUpClassBaseData(cls, *args, **kwargs): + super().setUpClassBaseData(*args, **kwargs) + cls.batch = cls._create_picking_batch( + [ + [cls.BatchProduct(product=cls.product_a, quantity=1)], + [cls.BatchProduct(product=cls.product_b, quantity=1)], + [cls.BatchProduct(product=cls.product_d, quantity=1)], + [cls.BatchProduct(product=cls.product_c, quantity=1)], + [cls.BatchProduct(product=cls.product_b, quantity=1)], + ] + ) + cls._simulate_batch_selected(cls.batch, in_package=True) + cls.menu.sudo().move_line_processing_sort_order = "location_grouped_product" + return + + def test_custom_lines_order(self): + """The sorting of the lines in the batch groups lines with the same product""" + batch = self.batch + + expected_lines_order = self._assign_different_locations(batch) + + for expected_line in expected_lines_order: + # We are going to call this empoint once per line + # to simulate the fact that the lines have to be treated + # one at a time. + response = self.service.dispatch( + "confirm_start", + params={"picking_batch_id": batch.id}, + ) + returned_line = batch.move_line_ids.filtered( + lambda line: line.id == response["data"]["start_line"]["id"] + ) + self.assertEqual(returned_line.id, expected_line.id) + returned_line.state = "confirmed" + + def _assign_different_locations(self, batch): + # We assign one unique location to each line of the batch + # and we make sure each location has the sequence required for the test. + locations = self.env["stock.location"].search([], limit=5) + # The line with product A will have the lowest sequence. + line_product_a = batch.move_line_ids.filtered( + lambda line: line.product_id == self.product_a + ) + line_product_a.location_id = locations[0] + line_product_a.location_id.shopfloor_picking_sequence = 1 + line_product_a.state = "assigned" + + # One of the lines with product B will have the second lowest sequence. + line_product_b_1 = fields.first( + batch.move_line_ids.filtered(lambda line: line.product_id == self.product_b) + ) + line_product_b_1.location_id = locations[1] + line_product_b_1.location_id.shopfloor_picking_sequence = 2 + line_product_b_1.state = "assigned" + + # The line with product D will have the third lowest sequence. + line_product_d = batch.move_line_ids.filtered( + lambda line: line.product_id == self.product_d + ) + line_product_d.location_id = locations[2] + line_product_d.location_id.shopfloor_picking_sequence = 3 + line_product_d.state = "assigned" + + # The line with product C will have the second highest sequence. + line_product_c = batch.move_line_ids.filtered( + lambda line: line.product_id == self.product_c + ) + line_product_c.location_id = locations[3] + line_product_c.location_id.shopfloor_picking_sequence = 4 + line_product_c.state = "assigned" + + # The other line with product B will have the highest sequence. + line_product_b_2 = batch.move_line_ids.filtered( + lambda line: line.product_id == self.product_b + and line.location_id != locations[1] + ) + line_product_b_2.location_id = locations[4] + line_product_b_2.location_id.shopfloor_picking_sequence = 5 + line_product_b_2.state = "assigned" + + # Return the lines in the order we expect once we sort by product. + return ( + line_product_a, + line_product_b_1, + line_product_b_2, + line_product_d, + line_product_c, + ) diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml index 5617de5f9e..380e3b2250 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -169,6 +169,26 @@ /> + + + + + + + + +