diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index f891ae7762..6660775778 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -6,7 +6,7 @@ { "name": "Shopfloor", "summary": "manage warehouse operations with barcode scanners", - "version": "16.0.2.2.0", + "version": "16.0.2.2.1", "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", diff --git a/shopfloor/actions/move_line_search.py b/shopfloor/actions/move_line_search.py index f341d122b3..a608652ccd 100644 --- a/shopfloor/actions/move_line_search.py +++ b/shopfloor/actions/move_line_search.py @@ -1,5 +1,8 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2024 ACSONE SA/NV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.tools import safe_eval + from odoo.addons.component.core import Component @@ -20,9 +23,42 @@ def picking_types(self): self.work, "picking_types", self.env["stock.picking.type"].browse() ) - def _search_move_lines_by_location_domain( + @property + def additional_domain(self): + return getattr(self.work, "additional_domain", []) + + @property + def sort_order(self): + return getattr(self.work, "sort_order", "priority") + + @property + def sort_order_custom_code(self): + return getattr(self.work, "sort_order_custom_code", None) + + def _get_additional_domain_eval_context(self): + """Prepare the context used when evaluating the additional domain + :returns: dict -- evaluation context given to safe_eval + """ + return { + "datetime": safe_eval.datetime, + "dateutil": safe_eval.dateutil, + "time": safe_eval.time, + "uid": self.env.uid, + "user": self.env.user, + } + + def _sort_key_custom_code_eval_context(self, line): + return { + "line": line, + "key": None, + "get_sort_key_priority": self._sort_key_move_lines_priority, + "get_sort_key_location": self._sort_key_move_lines_location, + "get_sort_key_assigned_to_current_user": self._sort_key_assigned_to_current_user, + } + + def _search_move_lines_domain( self, - locations, + locations=None, picking_type=None, package=None, product=None, @@ -32,16 +68,26 @@ def _search_move_lines_by_location_domain( # When True, adds the package in the domain even if the package is False enforce_empty_package=False, ): + """Return a domain to search move lines. + + Be careful on the use of the picking_type parameter. This paramater can take + a recordset or None as value. The interpretation of the value is as follows: + * If picking_type is None, the domain will be filtered on all picking types + defined in the work. (most probably those defined on the menu) + * If picking_type is a recordset, the domain will be filtered on the given + picking types if the recordset is not empty. If the recordset is empty, + the domain will not be filtered on any picking type. + """ domain = [ - ("location_id", "child_of", locations.ids), ("qty_done", "=", 0), ("state", "in", ("assigned", "partially_available")), ] - if picking_type: - # auto_join in place for this field - domain += [("picking_id.picking_type_id", "=", picking_type.id)] - elif self.picking_types: - domain += [("picking_id.picking_type_id", "in", self.picking_types.ids)] + picking_types = picking_type if picking_type is not None else self.picking_types + if picking_types: + domain += [("picking_id.picking_type_id", "in", picking_types.ids)] + locations = locations or picking_types.default_location_src_id + if locations: + domain += [("location_id", "child_of", locations.ids)] if package or package is not None and enforce_empty_package: domain += [("package_id", "=", package.id if package else False)] if product: @@ -49,23 +95,26 @@ def _search_move_lines_by_location_domain( if lot: domain += [("lot_id", "=", lot.id)] if match_user: + # we only want to see the lines assigned to the current user domain += [ - "|", - ("shopfloor_user_id", "=", False), - ("shopfloor_user_id", "=", self.env.uid), + ("shopfloor_user_id", "in", (False, self.env.uid)), + ("picking_id.user_id", "in", (False, self.env.uid)), ] if picking_ready: domain += [("picking_id.state", "=", "assigned")] + if self.additional_domain: + eval_context = self._get_additional_domain_eval_context() + domain += safe_eval.safe_eval(self.additional_domain, eval_context) return domain - def search_move_lines_by_location( + def search_move_lines( self, - locations, + locations=None, picking_type=None, package=None, product=None, lot=None, - order="priority", + order=None, match_user=False, sort_keys_func=None, picking_ready=True, @@ -73,7 +122,7 @@ def search_move_lines_by_location( ): """Find lines that potentially need work in given locations.""" move_lines = self.env["stock.move.line"].search( - self._search_move_lines_by_location_domain( + self._search_move_lines_domain( locations, picking_type, package, @@ -84,36 +133,79 @@ def search_move_lines_by_location( enforce_empty_package=enforce_empty_package, ) ) + order = order or self.sort_order sort_keys_func = sort_keys_func or self._sort_key_move_lines(order) move_lines = move_lines.sorted(sort_keys_func) return move_lines - @staticmethod - def _sort_key_move_lines(order): + def _sort_key_move_lines(self, order=None): """Return a sorting function to order lines.""" + if order is None: + return lambda line: tuple() if order == "priority": - # make prority negative to keep sorting ascending - return lambda line: ( - -int(line.move_id.priority or "0"), - line.move_id.date, - line.move_id.id, - ) - elif order == "location": - return lambda line: ( - line.location_id.shopfloor_picking_sequence or "", - line.location_id.name, - line.move_id.date, - line.move_id.id, - ) - return lambda line: line + return self._sort_key_move_lines_priority + + if order == "location": + return self._sort_key_move_lines_location + + if order == "custom_code": + return self._sort_key_custom_code + + if order == "assigned_to_current_user": + return self._sort_key_assigned_to_current_user + + raise ValueError(f"Unknown order '{order}'") + + def _sort_key_move_lines_priority(self, line): + # make prority negative to keep sorting ascending + return self._sort_key_assigned_to_current_user(line) + ( + -int(line.move_id.priority or "0"), + line.move_id.date, + line.move_id.id, + ) + + def _sort_key_move_lines_location(self, line): + return self._sort_key_assigned_to_current_user(line) + ( + line.location_id.shopfloor_picking_sequence or "", + line.location_id.name, + line.move_id.date, + line.move_id.id, + ) + + def _sort_key_assigned_to_current_user(self, line): + user_id = line.shopfloor_user_id.id or line.picking_id.user_id.id or None + # Determine sort priority + # Priority 0: Assigned to the current user + # Priority 1: Not assigned to any user + # Priority 2: Assigned to a different user + if user_id == self.env.uid: + priority = 0 + elif user_id is None: + priority = 1 + else: + priority = 2 + return (priority,) + + def _sort_key_custom_code(self, line): + context = self._sort_key_custom_code_eval_context(line) + safe_eval.safe_eval( + self.sort_order_custom_code, context, mode="exec", nocopy=True + ) + return context["key"] def counters_for_lines(self, lines): # Not using mapped/filtered to support simple lists and generators - priority_lines = [x for x in lines if int(x.picking_id.priority or "0") > 0] + priority_lines = set() + priority_pickings = set() + for line in lines: + if int(line.move_id.priority or "0") > 0: + priority_lines.add(line) + if int(line.picking_id.priority or "0") > 0: + priority_pickings.add(line.picking_id) return { "lines_count": len(lines), "picking_count": len({x.picking_id.id for x in lines}), "priority_lines_count": len(priority_lines), - "priority_picking_count": len({x.picking_id.id for x in priority_lines}), + "priority_picking_count": len(priority_pickings), } diff --git a/shopfloor/data/shopfloor_scenario_data.xml b/shopfloor/data/shopfloor_scenario_data.xml index 41768a3db3..62228dc53b 100644 --- a/shopfloor/data/shopfloor_scenario_data.xml +++ b/shopfloor/data/shopfloor_scenario_data.xml @@ -21,7 +21,9 @@ "multiple_move_single_pack": true, "no_prefill_qty": true, "scan_location_or_pack_first": true, - "allow_alternative_destination_package": true + "allow_alternative_destination_package": true, + "allow_move_line_search_sort_order": false, + "allow_move_line_search_additional_domain": true } @@ -68,7 +70,10 @@ "allow_unreserve_other_moves": true, "allow_ignore_no_putaway_available": true, "allow_get_work": true, - "no_prefill_qty": true + "no_prefill_qty": true, + "allow_move_line_search_sort_order": true, + "allow_move_line_search_additional_domain": true + } diff --git a/shopfloor/migrations/16.0.2.2.1/post-init_search_move_line_options.py b/shopfloor/migrations/16.0.2.2.1/post-init_search_move_line_options.py new file mode 100644 index 0000000000..5f3370c4cb --- /dev/null +++ b/shopfloor/migrations/16.0.2.2.1/post-init_search_move_line_options.py @@ -0,0 +1,33 @@ +# Copyright 2024 ACSONE SA/NV (http://acsone.eu) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import json +import logging + +from odoo import SUPERUSER_ID, api + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + if not version: + return + env = api.Environment(cr, SUPERUSER_ID, {}) + zone_picking = env.ref("shopfloor.scenario_zone_picking") + _update_scenario_options(zone_picking, sort_order=False, additional_domain=True) + location_content_transfer = env.ref("shopfloor.scenario_location_content_transfer") + _update_scenario_options( + location_content_transfer, sort_order=True, additional_domain=True + ) + + +def _update_scenario_options(scenario, sort_order=True, additional_domain=True): + options = scenario.options + options["allow_move_line_search_sort_order"] = sort_order + options["allow_move_line_search_additional_domain"] = additional_domain + options_edit = json.dumps(options or {}, indent=4, sort_keys=True) + scenario.write({"options_edit": options_edit}) + _logger.info( + "Option allow_alternative_destination_package added to scenario %s", + scenario.name, + ) diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index bb0f0e0764..a3a345b072 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -1,6 +1,8 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2024 ACSONE SA/NV (http://www.acsone.eu) # 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 @@ -227,6 +229,28 @@ class ShopfloorMenu(models.Model): compute="_compute_allow_alternative_destination_package_is_possible" ) + move_line_search_additional_domain_is_possible = fields.Boolean( + compute="_compute_move_line_search_additional_domain_is_possible" + ) + move_line_search_additional_domain = fields.Char( + string="Additional domain used when searching move lines" + ) + move_line_search_sort_order_is_possible = fields.Boolean( + compute="_compute_move_line_search_sort_order_is_possible" + ) + move_line_search_sort_order = fields.Selection( + selection=[ + ("priority", "Priority"), + ("location", "Location"), + ("custom_code", "Custom code"), + ], + string="Sort method used when searching move lines", + default="priority", + ) + move_line_search_sort_order_custom_code = fields.Text( + string="Custom sort key code", help="Python code to sort move lines. " + ) + @api.onchange("unload_package_at_destination") def _onchange_unload_package_at_destination(self): # Uncheck pick_pack_same_time when unload_package_at_destination is set to True @@ -458,3 +482,64 @@ 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_search_additional_domain_is_possible(self): + for menu in self: + menu.move_line_search_additional_domain_is_possible = ( + menu.scenario_id.has_option("allow_move_line_search_additional_domain") + ) + + @api.depends("scenario_id") + def _compute_move_line_search_sort_order_is_possible(self): + for menu in self: + menu.move_line_search_sort_order_is_possible = menu.scenario_id.has_option( + "allow_move_line_search_sort_order" + ) + + @api.constrains( + "move_line_search_sort_order", "move_line_search_sort_order_custom_code" + ) + def _check_move_line_search_sort_order_custom_code(self): + for menu in self: + if ( + menu.move_line_search_sort_order == "custom_code" + and not menu.move_line_search_sort_order_custom_code + ): + raise exceptions.ValidationError( + _( + "Custom sort key code is required when 'Custom code' is selected." + ) + ) + if ( + menu.move_line_search_sort_order != "custom_code" + and menu.move_line_search_sort_order_custom_code + ): + raise exceptions.ValidationError( + _( + "Custom sort key code is only allowed when 'Custom code' is selected." + ) + ) + code = ( + menu.move_line_search_sort_order_custom_code + and menu.move_line_search_sort_order_custom_code.strip() + ) + if code: + msg = test_python_expr(code, mode="exec") + if msg: + raise exceptions.ValidationError(msg) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get("move_line_search_sort_order", "") != "custom_code": + vals["move_line_search_sort_order_custom_code"] = "" + return super().create(vals_list) + + def write(self, vals): + if ( + "move_line_search_sort_order" in vals + and vals["move_line_search_sort_order"] != "custom_code" + ): + vals["move_line_search_sort_order_custom_code"] = "" + return super().write(vals) diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 8b07a9f710..9f5408bfde 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -2,7 +2,8 @@ # 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 odoo import _ +from odoo.fields import first from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -208,24 +209,6 @@ def start_or_recover(self): response = self._recover_started_picking() return response or self._response_for_start() - def _find_location_move_lines_domain(self, location): - return [ - ("location_id", "=", location.id), - ("qty_done", "=", 0), - ("state", "in", ("assigned", "partially_available")), - ("picking_id.user_id", "in", (False, self.env.uid)), - ("picking_id.state", "=", "assigned"), - ] - - def _find_location_move_lines_from_scan_location(self, *args, **kwargs): - return self._find_location_move_lines(*args, **kwargs) - - def _find_location_move_lines(self, location): - """Find lines that potentially are to move in the location""" - return self.env["stock.move.line"].search( - self._find_location_move_lines_domain(location) - ) - def _create_moves_from_location(self, location): # get all quants from the scanned location quants = self.env["stock.quant"].search( @@ -251,24 +234,12 @@ def _create_moves_from_location(self, location): return self.env["stock.move"].create(move_vals_list) def _find_location_to_work_from(self): - location = self.env["stock.location"] - pickings = self.env["stock.picking"].search( - [ - ("picking_type_id", "in", self.picking_types.ids), - ("state", "=", "assigned"), - ("user_id", "in", (False, self.env.user.id)), - ], - order="user_id, priority desc, scheduled_date asc, id desc", - ) + move_lines = self.search_move_line.search_move_lines(match_user=True) + return first(move_lines).location_id - for next_picking in pickings: - move_lines = next_picking.move_line_ids.filtered( - lambda line: line.qty_done < line.reserved_uom_qty - ) - location = fields.first(move_lines).location_id - if location: - break - return location + def _select_move_lines_first_location(self, move_lines): + location = first(move_lines).location_id + return move_lines.filtered(lambda line: line.location_id == location) def find_work(self): """Find the next location to work from, for a user. @@ -285,14 +256,15 @@ def find_work(self): response = self._recover_started_picking() if response: return response + self._actions_for("lock").advisory(self._advisory_lock_find_work) - location = self._find_location_to_work_from() - if not location: + move_lines = self.search_move_line.search_move_lines(match_user=True) + if not move_lines: return self._response_for_start(message=self.msg_store.no_work_found()) - move_lines = self._find_location_move_lines(location) + move_lines = self._select_move_lines_first_location(move_lines) stock = self._actions_for("stock") stock.mark_move_line_as_picked(move_lines, quantity=0) - return self._response_for_scan_location(location=location) + return self._response_for_scan_location(location=move_lines.location_id) def _find_move_lines_to_cancel_work(self, location): unreserve = self._actions_for("stock.unreserve") @@ -356,7 +328,14 @@ def scan_location(self, barcode): # noqa: C901 message=self.msg_store.cannot_move_something_in_picking_type() ) - move_lines = self._find_location_move_lines_from_scan_location(location) + move_lines = self.search_move_line.search_move_lines( + locations=location, + match_user=True, + picking_type=self.env[ + "stock.picking.type" + ], # disable filtering on picking types + ) + move_lines = self._select_move_lines_first_location(move_lines) savepoint = self._actions_for("savepoint").new() unreserve = self._actions_for("stock.unreserve") diff --git a/shopfloor/services/menu.py b/shopfloor/services/menu.py index d2d90e4dba..2619ed869a 100644 --- a/shopfloor/services/menu.py +++ b/shopfloor/services/menu.py @@ -19,10 +19,13 @@ def _get_move_line_counters(self, record): # TODO: maybe to be improved w/ raw SQL as this run for each menu item # and it's called every time the menu is opened/gets refreshed move_line_search = self._actions_for( - "search_move_line", picking_types=record.picking_type_ids + "search_move_line", + picking_types=record.picking_type_ids, + additional_domain=record.move_line_search_additional_domain, + sort_order=record.move_line_search_sort_order, + sort_order_custom_code=record.move_line_search_sort_order_custom_code, ) - locations = record.picking_type_ids.mapped("default_location_src_id") - lines_per_menu = move_line_search.search_move_lines_by_location(locations) + lines_per_menu = move_line_search.search_move_lines() return move_line_search.counters_for_lines(lines_per_menu) def _one_record_parser(self, record): diff --git a/shopfloor/services/service.py b/shopfloor/services/service.py index bf10f67907..0c98e23ae0 100644 --- a/shopfloor/services/service.py +++ b/shopfloor/services/service.py @@ -15,7 +15,15 @@ class BaseShopfloorService(AbstractComponent): @property def search_move_line(self): # TODO: propagating `picking_types` should probably be default - return self._actions_for("search_move_line", propagate_kwargs=["picking_types"]) + return self._actions_for( + "search_move_line", + propagate_kwargs=[ + "picking_types", + "additional_domain", + "sort_order", + "sort_order_custom_code", + ], + ) class BaseShopfloorProcess(AbstractComponent): @@ -38,13 +46,31 @@ def picking_types(self): ) return self.work.picking_types + @property + def additional_domain(self): + return self.work.menu.move_line_search_additional_domain + + @property + def sort_order(self): + return self.work.menu.move_line_search_sort_order + + @property + def sort_order_custom_code(self): + return self.work.menu.move_line_search_sort_order_custom_code + @property def search_move_line(self): # TODO: picking types should be set somehow straight in the work context # by `_validate_headers_update_work_context` in this way # we can remove this override and the need to call `_get_process_picking_types` # every time. - return self._actions_for("search_move_line", picking_types=self.picking_types) + return self._actions_for( + "search_move_line", + picking_types=self.picking_types, + additional_domain=self.additional_domain, + sort_order=self.sort_order, + sort_order_custom_code=self.sort_order_custom_code, + ) def _check_picking_status(self, pickings, states=("assigned",)): """Check if given pickings can be processed. diff --git a/shopfloor/services/zone_picking.py b/shopfloor/services/zone_picking.py index 17b184b9c7..114ed95a88 100644 --- a/shopfloor/services/zone_picking.py +++ b/shopfloor/services/zone_picking.py @@ -315,7 +315,7 @@ def _counters_for_zone_lines(self, zone_lines): return self.search_move_line.counters_for_lines(zone_lines) def _picking_type_zone_lines(self, zone_location, picking_type): - return self.search_move_line.search_move_lines_by_location( + return self.search_move_line.search_move_lines( zone_location, picking_type=picking_type ) @@ -429,7 +429,7 @@ def _find_location_move_lines( enforce_empty_package=False, ): """Find lines that potentially need work in given locations.""" - return self.search_move_line.search_move_lines_by_location( + return self.search_move_line.search_move_lines( locations or self.zone_location, picking_type=picking_type or self.picking_type, order=self.lines_order, diff --git a/shopfloor/tests/__init__.py b/shopfloor/tests/__init__.py index 7f5c27e658..6f797356b0 100644 --- a/shopfloor/tests/__init__.py +++ b/shopfloor/tests/__init__.py @@ -1,8 +1,10 @@ from . import test_menu_counters +from . import test_menu_contrains from . import test_openapi from . import test_actions_change_package_lot from . import test_actions_data from . import test_actions_data_detail +from . import test_actions_search_move_line from . import test_actions_search from . import test_actions_stock from . import test_single_pack_transfer diff --git a/shopfloor/tests/test_actions_search_move_line.py b/shopfloor/tests/test_actions_search_move_line.py new file mode 100644 index 0000000000..20069694f4 --- /dev/null +++ b/shopfloor/tests/test_actions_search_move_line.py @@ -0,0 +1,128 @@ +# Copyright 2024 ACSONE SA/NV (http://www.acsone.eu) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +# pylint: disable=missing-return +from contextlib import contextmanager + +from odoo.addons.base_rest.controllers.main import _PseudoCollection +from odoo.addons.component.core import WorkContext + +from .common import CommonCase + + +class TestActionsSearchMoveLine(CommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.picking_user1 = cls._create_picking( + lines=[(cls.product_a, 10)], confirm=True + ) + cls.picking_user2 = cls._create_picking( + lines=[(cls.product_a, 10)], confirm=True + ) + cls.picking_no_user = cls._create_picking( + lines=[(cls.product_a, 10)], confirm=True + ) + + cls.pickings = cls.picking_no_user | cls.picking_user1 | cls.picking_user2 + cls._fill_stock_for_moves(cls.pickings.move_ids) + cls.pickings.action_assign() + cls.move_lines = cls.pickings.move_line_ids + cls.user1 = cls.env.user.copy({"name": "User 1", "login": "user1"}) + cls.user2 = cls.env.user.copy({"name": "User 2", "login": "user2"}) + cls.picking_no_user.user_id = False + cls.picking_user1.user_id = cls.user1.id + cls.picking_user2.move_line_ids.write({"shopfloor_user_id": cls.user2.id}) + cls.picking_user2.user_id = cls.user2.id + + @contextmanager + def work_on_actions(self, user=None, **params): + params = params or {} + env = self.env(user=user) if user else self.env + collection = _PseudoCollection("shopfloor.action", env) + yield WorkContext( + model_name="rest.service.registration", collection=collection, **params + ) + + @contextmanager + def search_move_line(self, user=None): + with self.work_on_actions(user) as work: + move_line_search = work.component(usage="search_move_line") + yield move_line_search + + @classmethod + def setUpClassVars(cls): + super().setUpClassVars() + cls.wh = cls.env.ref("stock.warehouse0") + cls.picking_type = cls.wh.out_type_id + + def test_search_move_line_sorter(self): + with self.search_move_line() as move_line_search: + move_lines = self.move_lines.sorted( + move_line_search._sort_key_move_lines(order="assigned_to_current_user") + ) + # we must get operations in the following order: + # * no shopfloor user and not user assigned to picking + # * no shopfloor user and user assigned to picking + # * shopfloor user or user assigned to picking + + self.assertFalse(move_lines[0].shopfloor_user_id) + self.assertFalse(move_lines[0].picking_id.user_id) + self.assertFalse(move_lines[1].shopfloor_user_id) + self.assertTrue(move_lines[1].picking_id.user_id) + self.assertTrue(move_lines[2].shopfloor_user_id) + + with self.search_move_line(user=self.user1) as move_line_search: + move_lines = self.move_lines.sorted( + move_line_search._sort_key_move_lines(order="assigned_to_current_user") + ) + # user1 is only assigned at picking level + # we must get operations in the following order: + # * no shopfloor user but user assigned to picking + # * no shopfloor user and not user assigned to picking + # * shopfloor user or user assigned to picking + self.assertFalse(move_lines[0].shopfloor_user_id) + self.assertEqual(move_lines[0].picking_id.user_id, self.user1) + self.assertFalse(move_lines[1].shopfloor_user_id) + self.assertTrue(move_lines[2].shopfloor_user_id) + + with self.search_move_line(user=self.user2) as move_line_search: + move_lines = self.move_lines.sorted( + move_line_search._sort_key_move_lines(order="assigned_to_current_user") + ) + # user2 is only assigned at move level + # we must get operations in the following order: + # * shopfloor user or user assigned to picking + # * no shopfloor user and no user assigned to picking + # * no shopfloor user and user assigned to picking + self.assertEqual(move_lines[0].shopfloor_user_id, self.user2) + self.assertFalse(move_lines[1].shopfloor_user_id) + self.assertFalse(move_lines[1].picking_id.user_id) + self.assertTrue(move_lines[2].picking_id.user_id) + + def test_search_move_line_match_user(self): + with self.search_move_line() as move_line_search: + move_lines = move_line_search.search_move_lines( + locations=self.picking_type.default_location_src_id, + match_user=True, + ) + # we must only get picking not assigned to a user + self.assertFalse(move_lines.picking_id.user_id) + self.assertFalse(move_lines.shopfloor_user_id) + + with self.search_move_line(user=self.user1) as move_line_search: + move_lines = move_line_search.search_move_lines( + locations=self.picking_type.default_location_src_id, + match_user=True, + ) + # we must only get picking assigned to user1 or not assigned to any user + self.assertEqual(move_lines.picking_id.user_id, self.user1) + self.assertFalse(move_lines.shopfloor_user_id) + + with self.search_move_line(user=self.user2) as move_line_search: + move_lines = move_line_search.search_move_lines( + locations=self.picking_type.default_location_src_id, + match_user=True, + ) + # we must only get picking assigned to user2 + self.assertEqual(move_lines.picking_id.user_id, self.user2) + self.assertEqual(move_lines.shopfloor_user_id, self.user2) diff --git a/shopfloor/tests/test_location_content_transfer_get_work.py b/shopfloor/tests/test_location_content_transfer_get_work.py index 287dcfddbc..f593ccd462 100644 --- a/shopfloor/tests/test_location_content_transfer_get_work.py +++ b/shopfloor/tests/test_location_content_transfer_get_work.py @@ -105,6 +105,47 @@ def test_find_work_work_found(self): ) self.assertEqual(response["next_state"], "scan_destination_all") + def test_find_work_additional_domain(self): + next_location = self.service._find_location_to_work_from() + self.assertTrue(next_location) + self.menu.sudo().move_line_search_additional_domain = [ + ("picking_id.id", "not in", self.pickings.ids) + ] + next_location = self.service._find_location_to_work_from() + self.assertFalse(next_location) + response = self.service.dispatch("find_work", params={}) + self.assert_response( + response, + next_state="get_work", + data={}, + message=self.service.msg_store.no_work_found(), + ) + + def test_find_work_custom_sort_key(self): + # fmt: off + self.menu.sudo().write( + { + "move_line_search_sort_order": "custom_code", + "move_line_search_sort_order_custom_code": + f"key = (-1 if line.location_id.id == {self.content_loc.id} else 10, )", + } + ) + # fmt: on + + next_location = self.service._find_location_to_work_from() + self.assertEqual(next_location, self.content_loc) + self.menu.sudo().move_line_search_sort_order_custom_code = ( + f"key = (-1 if line.location_id.id == {self.content_loc2.id} else 10, )" + ) + next_location = self.service._find_location_to_work_from() + self.assertEqual(next_location, self.content_loc2) + self.menu.sudo().move_line_search_sort_order_custom_code = f""" +key = (-1 if line.location_id.id == {self.content_loc2.id} else 10, ) + \ + get_sort_key_assigned_to_current_user(line) + """ + next_location = self.service._find_location_to_work_from() + self.assertEqual(next_location, self.content_loc2) + def test_cancel_work(self): next_location = self.service._find_location_to_work_from() stock = self.service._actions_for("stock") diff --git a/shopfloor/tests/test_location_content_transfer_mix.py b/shopfloor/tests/test_location_content_transfer_mix.py index 45adf17ad2..9d9b53f6f6 100644 --- a/shopfloor/tests/test_location_content_transfer_mix.py +++ b/shopfloor/tests/test_location_content_transfer_mix.py @@ -370,8 +370,8 @@ def test_with_zone_picking2(self): lambda l: not l.shopfloor_user_id and l.location_id == dest_location2 ) picking_before = pack_second_pallet.picking_id - move_lines = self.service._find_location_move_lines( - pack_second_pallet.location_id + move_lines = self.service.search_move_line.search_move_lines( + locations=pack_second_pallet.location_id ) response = self._location_content_transfer_process_line(pack_second_pallet) response_packages = response["data"]["scan_destination_all"]["package_levels"] diff --git a/shopfloor/tests/test_menu_contrains.py b/shopfloor/tests/test_menu_contrains.py new file mode 100644 index 0000000000..56d71911c1 --- /dev/null +++ b/shopfloor/tests/test_menu_contrains.py @@ -0,0 +1,40 @@ +# Copyright 2024 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import exceptions + +from .test_menu_base import MenuCountersCommonCase + + +class TestMenuContrains(MenuCountersCommonCase): + def test_move_line_search_sort_order_custom_code_invalid(self): + + with self.assertRaises(exceptions.ValidationError): + # wrong indentation in python code + self.menu1.sudo().write( + { + "move_line_search_sort_order_custom_code": """ + key = 1 + toto(1) +""", + "move_line_search_sort_order": "custom_code", + } + ) + + self.menu1.sudo().write( + { + "move_line_search_sort_order_custom_code": "key = 1", + "move_line_search_sort_order": "custom_code", + } + ) + + def test_move_line_search_sort_order_mismatch(self): + with self.assertRaises(exceptions.ValidationError): + self.menu1.sudo().move_line_search_sort_order = "custom_code" + + self.menu1.sudo().write( + { + "move_line_search_sort_order_custom_code": "key = 1", + "move_line_search_sort_order": "priority", + } + ) + self.assertFalse(self.menu1.move_line_search_sort_order_custom_code) diff --git a/shopfloor/tests/test_menu_counters.py b/shopfloor/tests/test_menu_counters.py index 974becbdd4..c7da95fd03 100644 --- a/shopfloor/tests/test_menu_counters.py +++ b/shopfloor/tests/test_menu_counters.py @@ -24,6 +24,9 @@ def test_menu_search_profile1(self): .sudo() .search([("profile_id", "=", self.wms_profile.id)]) ) + # ensures expected counters are in the expected menu items + self.assertIn(self.menu1, expected_menu_items) + self.assertIn(self.menu2, expected_menu_items) service = self.get_service("menu", profile=self.wms_profile) response = service.dispatch("search") self._assert_menu_response( @@ -33,6 +36,51 @@ def test_menu_search_profile1(self): ) def test_menu_search_profile2(self): + expected_menu_items = ( + self.env["shopfloor.menu"] + .sudo() + .search([("profile_id", "=", self.wms_profile2.id)]) + ) + expected_counters = { + expected_menu_item[0].id: { + "lines_count": 0, + "picking_count": 0, + "priority_lines_count": 0, + "priority_picking_count": 0, + } + for expected_menu_item in expected_menu_items + } + service = self.get_service("menu", profile=self.wms_profile2) + response = service.dispatch("search") + self._assert_menu_response( + response, + expected_menu_items.sorted("sequence"), + expected_counters=expected_counters, + ) + + def test_menu_counter_priority(self): + """Test that priority counters are correctly computed. + + Priority on lines must be based on the move priority. + Priority on pickings must be based on the picking priority. + """ + self.pickings.write({"priority": "0"}) + self.pickings.move_ids.write({"priority": "0"}) + pickings_menu_2 = self.pickings.filtered( + lambda p: p.picking_type_id == self.menu2_picking_type + ) + # Set priority on pickings only for first picking of menu 2 + pickings_menu_2[0].write({"priority": "1"}) + pickings_menu_2[0].move_ids.write({"priority": "0"}) + + # set priority on move lines only for second picking of menu 2 + pickings_menu_2[1].write({"priority": "0"}) + pickings_menu_2[1].move_ids[0].write({"priority": "1"}) + + # set priority on both move lines and pickings for third picking of menu 2 + pickings_menu_2[2].write({"priority": "1"}) + pickings_menu_2[2].move_ids.write({"priority": "1"}) + expected_counters = { self.menu1.id: { "lines_count": 2, @@ -40,6 +88,41 @@ def test_menu_search_profile2(self): "priority_lines_count": 0, "priority_picking_count": 0, }, + self.menu2.id: { + "lines_count": 6, + "picking_count": 3, + "priority_lines_count": 1 + len(pickings_menu_2[2].move_ids), + "priority_picking_count": 2, + }, + } + expected_menu_items = ( + self.env["shopfloor.menu"] + .sudo() + .search([("profile_id", "=", self.wms_profile.id)]) + ) + # ensures expected counters are in the expected menu items + self.assertIn(self.menu1, expected_menu_items) + self.assertIn(self.menu2, expected_menu_items) + service = self.get_service("menu", profile=self.wms_profile) + response = service.dispatch("search") + self._assert_menu_response( + response, + expected_menu_items.sorted("sequence"), + expected_counters=expected_counters, + ) + + def test_menu_search_additional_domain(self): + self.menu1.sudo().move_line_search_additional_domain = [ + ("picking_id.priority", "=", "1") + ] + + expected_counters = { + self.menu1.id: { + "lines_count": 0, + "picking_count": 0, + "priority_lines_count": 0, + "priority_picking_count": 0, + }, self.menu2.id: { "lines_count": 6, "picking_count": 3, @@ -50,9 +133,36 @@ def test_menu_search_profile2(self): expected_menu_items = ( self.env["shopfloor.menu"] .sudo() - .search([("profile_id", "=", self.wms_profile2.id)]) + .search([("profile_id", "=", self.wms_profile.id)]) ) - service = self.get_service("menu", profile=self.wms_profile2) + # ensures expected counters are in the expected menu items + self.assertIn(self.menu1, expected_menu_items) + self.assertIn(self.menu2, expected_menu_items) + service = self.get_service("menu", profile=self.wms_profile) + response = service.dispatch("search") + self._assert_menu_response( + response, + expected_menu_items.sorted("sequence"), + expected_counters=expected_counters, + ) + + self.menu1.sudo().move_line_search_additional_domain = [ + ("picking_id.priority", "=", "0") + ] + expected_counters = { + self.menu1.id: { + "lines_count": 2, + "picking_count": 2, + "priority_lines_count": 0, + "priority_picking_count": 0, + }, + self.menu2.id: { + "lines_count": 6, + "picking_count": 3, + "priority_lines_count": 0, + "priority_picking_count": 0, + }, + } response = service.dispatch("search") self._assert_menu_response( response, diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml index 5617de5f9e..6fbee3a6eb 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -169,6 +169,60 @@ /> + + + + + +
+ +

Three variables are available:

+
    +
  • line: The move line for with the key must be computed. +
  • +
  • get_sort_key_priority: Return sort key to sort by priority. (return a tuple) +
  • +
  • get_sort_key_location: Return sort key to sort by location. (return a tuple) +
  • +
  • get_sort_key_assigned_to_current_user: Return sort key to get line assigned to current user first. (return a tuple) +
  • +
  • To assign the key value: key = get_sort_key_assigned_to_current_user(line) + (line.x, )
  • +
+ +
+ +