From a21ab50aa0b5ce881b35406a36700b30d3081419 Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Tue, 12 May 2026 20:21:07 +0200 Subject: [PATCH 1/4] [IMP] shopfloor: Location Content Transfer - Improve message If the move line is not yet created, we need to know which destination location has been computed in order to display it to user. We add also the product name --- shopfloor/actions/message.py | 16 +++++++++------- .../services/location_content_transfer.py | 11 ++++++----- .../test_location_content_transfer_putaway.py | 18 +++++++++++++----- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index e8962bf19bc..02b57d3c1c3 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -657,13 +657,15 @@ def location_content_transfer_complete(self, location_src, location_dest): ), } - def location_content_unable_to_transfer(self, location_dest): - return { - "message_type": "error", - "body": _( - "The content of {} cannot be transferred with this scenario." - ).format(location_dest.name), - } + def location_content_unable_to_transfer(self, move, location, location_dest): + message = _( + "The content of %(location)s cannot be transferred to " + "%(location_dest)s with this scenario for product %(product_name)s.", + location=location.name, + location_dest=location_dest.name, + product_name=move.product_id.display_name, + ) + return {"message_type": "error", "body": message} def product_in_multiple_sublocation(self, product): return { diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index e68bdcc8b65..86771895450 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -386,12 +386,13 @@ def scan_location(self, barcode): # noqa: C901 move_lines = new_moves.move_line_ids for line in move_lines: if not self.is_dest_location_valid(line.move_id, line.location_dest_id): - savepoint.rollback() - return self._response_for_start( - message=self.msg_store.location_content_unable_to_transfer( - location - ) + move = line.move_id + location_dest = line.location_id + message = self.msg_store.location_content_unable_to_transfer( + move, location, location_dest ) + savepoint.rollback() + return self._response_for_start(message=message) stock = self._actions_for("stock") if self.work.menu.ignore_no_putaway_available and stock.no_putaway_available( diff --git a/shopfloor/tests/test_location_content_transfer_putaway.py b/shopfloor/tests/test_location_content_transfer_putaway.py index fb073f8bbc8..ae737b13c62 100644 --- a/shopfloor/tests/test_location_content_transfer_putaway.py +++ b/shopfloor/tests/test_location_content_transfer_putaway.py @@ -129,15 +129,23 @@ def test_putaway_move_dest_not_child_of_picking_type_dest(self): response = self.service.dispatch( "scan_location", params={"barcode": self.test_loc.barcode} ) + current_moves = self.env["stock.move"].search( + [("location_id", "=", self.test_loc.id), ("state", "=", "assigned")] + ) + self.assertEqual(existing_moves, current_moves) + + test_move = self.env["stock.move"].new( + { + "product_id": self.product_a.id, + "location_dest_id": self.picking_type.default_location_dest_id.id, + "location_id": self.test_loc.id, + } + ) self.assert_response( response, next_state="scan_location", data=self.ANY, message=self.service.msg_store.location_content_unable_to_transfer( - self.test_loc + test_move, self.test_loc, self.test_loc ), ) - current_moves = self.env["stock.move"].search( - [("location_id", "=", self.test_loc.id), ("state", "=", "assigned")] - ) - self.assertEqual(existing_moves, current_moves) From fcacf08105025efd8e2289c7792f4b742f9b9b67 Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Tue, 12 May 2026 20:24:22 +0200 Subject: [PATCH 2/4] [IMP] shopfloor_base: Store message queue In some situations, it is convenient to store the message in a message queue at different levels of shopfloor stack and then restitute them in the response. --- shopfloor_base/actions/message.py | 12 ++++++++++++ shopfloor_base/services/service.py | 13 +++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/shopfloor_base/actions/message.py b/shopfloor_base/actions/message.py index 4c8e111772f..b16c46bbcec 100644 --- a/shopfloor_base/actions/message.py +++ b/shopfloor_base/actions/message.py @@ -1,5 +1,6 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + from odoo import _ from odoo.addons.component.core import Component @@ -19,6 +20,17 @@ class MessageAction(Component): _inherit = "shopfloor.process.action" _usage = "message" + _message_queue = [] + + @property + def message_queue(self): + return self._message_queue + + def add_message(self, value): + if not isinstance(value, str): + raise TypeError("You should set a string to message queue!") + self._message_queue.append(value) + def generic_record_not_found(self): return { "message_type": "error", diff --git a/shopfloor_base/services/service.py b/shopfloor_base/services/service.py index d537e323b01..f68a5321065 100644 --- a/shopfloor_base/services/service.py +++ b/shopfloor_base/services/service.py @@ -29,6 +29,8 @@ def __init__(self, work_context): self._profile = getattr(self.work, "profile", self.env["shopfloor.profile"]) self._menu = getattr(self.work, "menu", self.env["shopfloor.menu"]) + self._msg_store = self._actions_for("message") + def _get_api_spec(self, **params): return ShopfloorRestServiceAPISpec(self, **params) @@ -71,6 +73,13 @@ def _to_json(self, records): res.append(self._convert_one_record(record)) return res + def _get_message(self, message=None) -> dict: + if message is None: + message = {} + if self.msg_store and self.msg_store.message_queue and "body" in message: + message["body"] += "\n".join(self.msg_store.message_queue) + return message + def _response( self, base_response=None, data=None, next_state=None, message=None, popup=None ): @@ -113,7 +122,7 @@ def _response( elif data: response["data"] = data - if message: + if message := self._get_message(message): response["message"] = message if popup: @@ -197,7 +206,7 @@ def schema_detail(self): @property def msg_store(self): - return self._actions_for("message") + return self._msg_store if self._msg_store else self._actions_for("message") # TODO: maybe to be proposed to base_rest # TODO: add tests From ff07228205628a6085136559e805cf1a325d708f Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Wed, 13 May 2026 14:09:26 +0200 Subject: [PATCH 3/4] [IMP] shopfloor_base: Improve message queue management --- shopfloor_base/actions/message.py | 27 +++++++++++++++++++-- shopfloor_base/services/service.py | 38 ++++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/shopfloor_base/actions/message.py b/shopfloor_base/actions/message.py index b16c46bbcec..7143e02b354 100644 --- a/shopfloor_base/actions/message.py +++ b/shopfloor_base/actions/message.py @@ -5,6 +5,13 @@ from odoo.addons.component.core import Component +MESSAGE_TYPES = { + "info": 0, + "success": 1, + "warning": 2, + "error": 3, +} + class MessageAction(Component): """Provide message templates @@ -20,16 +27,22 @@ class MessageAction(Component): _inherit = "shopfloor.process.action" _usage = "message" + # A list of ShopfloorMessage _message_queue = [] @property def message_queue(self): return self._message_queue - def add_message(self, value): + def add_message(self, value, message_type="info"): if not isinstance(value, str): raise TypeError("You should set a string to message queue!") - self._message_queue.append(value) + if message_type not in MESSAGE_TYPES.keys(): + raise TypeError("You should use a correct Shopfloor message type!") + self._message_queue.append(ShopfloorMessage(value, message_type)) + + def clear_queue(self): + self._message_queue = [] def generic_record_not_found(self): return { @@ -58,3 +71,13 @@ def generic_record_not_found(self): # then all depending modules can simply create records they need # instea of overriding and polluting the component. # Additional goodie: users can edit messages via UI. + + +class ShopfloorMessage: + + body = str() + message_type = str() + + def __init__(self, body, message_type, **kwargs): + self.body = body + self.message_type = message_type diff --git a/shopfloor_base/services/service.py b/shopfloor_base/services/service.py index f68a5321065..ea13e930e49 100644 --- a/shopfloor_base/services/service.py +++ b/shopfloor_base/services/service.py @@ -12,6 +12,7 @@ from odoo.addons.component.core import AbstractComponent from ..actions.base_action import get_actions_for +from ..actions.message import MESSAGE_TYPES from ..apispec.service_apispec import ShopfloorRestServiceAPISpec @@ -73,11 +74,43 @@ def _to_json(self, records): res.append(self._convert_one_record(record)) return res + def _get_message_type(self, message): + """ + TODO: To be removed if we change the API to support multiple messages + (and types) per result. + """ + message_type = "info" + if message and "message_type" in message: + message_type = message["message_type"] + for message_element in self.msg_store.message_queue: + message_type = ( + message_element.message_type + if MESSAGE_TYPES[message_element.message_type] + > MESSAGE_TYPES[message_type] + else message_type + ) + return message_type + + def _get_message_body(self, message) -> str: + body = "" + if "body" in message: + body = message["body"] + if self.msg_store and self.msg_store.message_queue: + body += "\n".join(element.body for element in self.msg_store.message_queue) + return body + def _get_message(self, message=None) -> dict: + """ + This will combine the message contained in response and the + messages contained in the message queue + """ if message is None: message = {} - if self.msg_store and self.msg_store.message_queue and "body" in message: - message["body"] += "\n".join(self.msg_store.message_queue) + message_type = self._get_message_type(message) + body = self._get_message_body(message) + if message_type and body: + message["message_type"] = message_type + message["body"] = body return message def _response( @@ -124,6 +157,7 @@ def _response( if message := self._get_message(message): response["message"] = message + self.msg_store.clear_queue() if popup: response["popup"] = popup From 28b57f084ca05a324f53e65294e2d34bc8a51025 Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Wed, 13 May 2026 14:10:08 +0200 Subject: [PATCH 4/4] [FIX] shopfloor: Fix tests --- shopfloor/actions/message.py | 5 ++++- .../test_location_content_transfer_putaway.py | 16 ++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 02b57d3c1c3..3ed29059dfb 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -665,7 +665,10 @@ def location_content_unable_to_transfer(self, move, location, location_dest): location_dest=location_dest.name, product_name=move.product_id.display_name, ) - return {"message_type": "error", "body": message} + return { + "message_type": "error", + "body": message, + } def product_in_multiple_sublocation(self, product): return { diff --git a/shopfloor/tests/test_location_content_transfer_putaway.py b/shopfloor/tests/test_location_content_transfer_putaway.py index ae737b13c62..90326b89649 100644 --- a/shopfloor/tests/test_location_content_transfer_putaway.py +++ b/shopfloor/tests/test_location_content_transfer_putaway.py @@ -134,18 +134,14 @@ def test_putaway_move_dest_not_child_of_picking_type_dest(self): ) self.assertEqual(existing_moves, current_moves) - test_move = self.env["stock.move"].new( - { - "product_id": self.product_a.id, - "location_dest_id": self.picking_type.default_location_dest_id.id, - "location_id": self.test_loc.id, - } - ) + message = { + "message_type": "error", + "body": "The content of test cannot be transferred to test with " + "this scenario for product [A] Product A.", + } self.assert_response( response, next_state="scan_location", data=self.ANY, - message=self.service.msg_store.location_content_unable_to_transfer( - test_move, self.test_loc, self.test_loc - ), + message=message, )