From b0aae30cb1be47dce545b3a7e3b2f9e5c41ec32d Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Mon, 7 Apr 2025 16:03:09 +0200 Subject: [PATCH 1/3] [IMP] shopfloor_reception: Allows to interpret parsed result for multi attribute barcode As some barcodes structures (like GS1) interpretation possibilities. --- shopfloor_reception/__init__.py | 2 +- shopfloor_reception/models/__init__.py | 1 + shopfloor_reception/models/stock_move_line.py | 19 +++++ shopfloor_reception/services/reception.py | 77 +++++++++++++++++-- shopfloor_reception/tests/common.py | 1 - .../tests/test_multi_barcode.py | 59 ++++++++++++++ 6 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 shopfloor_reception/models/__init__.py create mode 100644 shopfloor_reception/models/stock_move_line.py create mode 100644 shopfloor_reception/tests/test_multi_barcode.py diff --git a/shopfloor_reception/__init__.py b/shopfloor_reception/__init__.py index ae16eb245f6..b447b57f1b2 100644 --- a/shopfloor_reception/__init__.py +++ b/shopfloor_reception/__init__.py @@ -1,2 +1,2 @@ -from . import services +from . import services, models from .hooks import post_init_hook, uninstall_hook diff --git a/shopfloor_reception/models/__init__.py b/shopfloor_reception/models/__init__.py new file mode 100644 index 00000000000..431f51c2747 --- /dev/null +++ b/shopfloor_reception/models/__init__.py @@ -0,0 +1 @@ +from . import stock_move_line diff --git a/shopfloor_reception/models/stock_move_line.py b/shopfloor_reception/models/stock_move_line.py new file mode 100644 index 00000000000..03bb9d6e2da --- /dev/null +++ b/shopfloor_reception/models/stock_move_line.py @@ -0,0 +1,19 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import models + + +class StockMoveLine(models.Model): + + _inherit = "stock.move.line" + + @property + def shopfloor_should_create_lot(self) -> bool: + """ + This will return True if the line should be used to create lots + """ + return bool( + (not self.lot_id and not self.lot_name) + and self.picking_type_use_create_lots + ) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index 8eab102accd..af0230488e6 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -5,12 +5,14 @@ import pytz +from decorator import contextmanager from odoo import fields from odoo.tools import float_compare from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component +from odoo.addons.shopfloor.actions.search import SearchResult from odoo.addons.shopfloor.utils import to_float @@ -48,6 +50,19 @@ class Reception(Component): _usage = "reception" _description = __doc__ + search_result = SearchResult() + + @contextmanager + def with_search_result(self, search_result: SearchResult): + """ + Use this context manager if you want to include search result in + component behavior. + + """ + self.search_result = search_result + yield + self.search_result = SearchResult() + def _check_picking_processible(self, pickings): # When returns are allowed, # the created picking might be empty and cannot be assigned. @@ -428,7 +443,13 @@ def _scan_line__by_product__return(self, picking, product): picking.action_assign() return self._scan_line__find_or_create_line(picking, return_move) + def _scan_line__dummy(self): + return + def _scan_line__by_product(self, picking, product): + """ + Try to find a move by product + """ moves = picking.move_ids.filtered(lambda m: m.product_id == product) # Only create a return if don't already have a maching reception move if not moves and self.work.menu.allow_return: @@ -488,6 +509,9 @@ def _scan_line__by_packaging(self, picking, packaging): return self._scan_line__find_or_create_line(picking, move) def _scan_line__by_lot(self, picking, lot): + """ + Try to find a move line by its lot (it should already be assigned) + """ lines = picking.move_line_ids.filtered( lambda l: ( lot == l.lot_id @@ -529,7 +553,11 @@ def _scan_line__fallback(self, picking, barcode): message=message, ) - def _check_move_available(self, move, message_code="product"): + def _check_move_available(self, move, message_code="product") -> bool: + """ + This will check if move is available to be selected by user + scan + """ if not move: message_code = message_code.capitalize() return self.msg_store.x_not_found_or_already_in_dest_package(message_code) @@ -538,6 +566,7 @@ def _check_move_available(self, move, message_code="product"): ) if move.product_uom_qty - move.quantity_done < 1 and not line_without_package: return self.msg_store.move_already_done() + return False def _set_quantity__check_quantity_done(self, selected_line): move = selected_line.move_id @@ -750,6 +779,7 @@ def _response_for_manual_selection(self): return self._response(next_state="manual_selection", data=data) def _response_for_set_lot(self, picking, line, message=None): + self._set_lot_from_parse(picking, line) return self._response( next_state="set_lot", data={ @@ -759,6 +789,38 @@ def _response_for_set_lot(self, picking, line, message=None): message=message, ) + def _set_lot_from_parse(self, picking, line): + """ + The lot has not been found in move lines before this call. + + Following the picking type configuration, set it: + + - on lot_id if record is found + - on lot_name if record is not found + - set expiration date if found in parse result + """ + if line.shopfloor_should_create_lot and self.search_result.parse_result: + expiration_date = None + lot_name = None + found = False + for result in self.search_result.parse_result: + if result.type == "lot": + if self.search_result.type == "lot" and self.search_result.record: + lot_id = self.search_result.record + lot_name = lot_id.name + found = True + else: + lot_name = result.value + found = True + if ( + result.type == "expiration_date" + and line.product_id.use_expiration_date + ): + expiration_date = result.value + + if found: + return self.set_lot(picking.id, line.id, lot_name, expiration_date) + def _align_display_product_uom_qty(self, line, response): # This method aligns product uom qties on move lines. # In the shopfloor context, we might have multiple users working at @@ -946,14 +1008,19 @@ def scan_line(self, picking_id, barcode): "product": self._scan_line__by_product, "packaging": self._scan_line__by_packaging, "lot": self._scan_line__by_lot, + "expiration_date": self._scan_line__dummy, } search = self._actions_for("search") search_result = search.find(barcode, handlers_by_type.keys()) # Fallback handler, returns a barcode not found error handler = handlers_by_type.get(search_result.type) - if handler: - return handler(picking, search_result.record) - return self._scan_line__fallback(picking, barcode) + + # This could maybe be removed if we pass instead + # the search result through all calls + with self.with_search_result(search_result): + if handler: + return handler(picking, search_result.record) + return self._scan_line__fallback(picking, barcode) def manual_select_move(self, move_id): move = self.env["stock.move"].browse(move_id) @@ -1045,7 +1112,7 @@ def set_lot( ) selected_line.lot_id = lot.id selected_line._onchange_lot_id() - elif expiration_date: + if expiration_date: selected_line.write({"expiration_date": expiration_date}) selected_line.lot_id.write({"expiration_date": expiration_date}) return self._response_for_set_lot(picking, selected_line) diff --git a/shopfloor_reception/tests/common.py b/shopfloor_reception/tests/common.py index 90bd62e323c..8e65d5a911b 100644 --- a/shopfloor_reception/tests/common.py +++ b/shopfloor_reception/tests/common.py @@ -1,7 +1,6 @@ # Copyright 2020 Camptocamp SA (http://www.camptocamp.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). # pylint: disable=missing-return - from odoo import fields from odoo.addons.shopfloor.tests.common import CommonCase as BaseCommonCase diff --git a/shopfloor_reception/tests/test_multi_barcode.py b/shopfloor_reception/tests/test_multi_barcode.py new file mode 100644 index 00000000000..82c9d40023a --- /dev/null +++ b/shopfloor_reception/tests/test_multi_barcode.py @@ -0,0 +1,59 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +import mock + +from odoo import fields + +from odoo.addons.shopfloor.actions.barcode_parser import BarcodeParser, BarcodeResult + +from .common import CommonCase + + +class TestStructuredBarcode(CommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.product_a.tracking = "lot" + cls.product_a.use_expiration_date = True + cls.picking_type.sudo().use_create_lots = True + + def test_scan_multiple_attribute_barcode(self): + """ + Check that scanning a product with multi attribute barcode + will fill in the lot + """ + picking = self._create_picking() + lot = self._create_lot() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + # selected_move_line.lot_id = lot + with mock.patch.object(BarcodeParser, "parse") as mock_parse: + mock_parse.return_value = [ + BarcodeResult(type="lot", value=lot.name, raw=lot.name), + BarcodeResult( + type="expiration_date", + value=fields.Date.to_date("2025-04-15"), + raw="250415", + ), + ] + response = self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + "barcode": lot.name, + }, + ) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + self.assertEqual( + selected_move_line.expiration_date, + fields.Datetime.to_datetime("2025-04-15"), + ) From b2737be08dc2976a887fbb1cc062a6cdb6d91b8f Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Mon, 7 Apr 2025 16:06:44 +0200 Subject: [PATCH 2/3] [DONT MERGE] test-requirements.txt --- test-requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test-requirements.txt b/test-requirements.txt index 689482e20df..8e7cf0ef2c5 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,2 +1,4 @@ vcrpy-unittest odoo_test_helper + +odoo-addon-shopfloor @ git+https://github.com/OCA/wms@refs/pull/1001/head#subdirectory=setup/shopfloor From 49f582171eac7214e130b32275f4d6a031774fc0 Mon Sep 17 00:00:00 2001 From: sbejaoui Date: Mon, 30 Jun 2025 18:07:11 +0200 Subject: [PATCH 3/3] [FIX] shopfloor_reception: remove _set_lot_from_parse _set_lot_from_parse method is redundant in _response_for_set_lot, as the lot is already set by the time this method is called additionally, _response_for_set_lot references an undefined parameter parse_result, which I couldn't find in class definition --- shopfloor_reception/services/reception.py | 33 ----------------------- 1 file changed, 33 deletions(-) diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index af0230488e6..f37e06d0712 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -779,7 +779,6 @@ def _response_for_manual_selection(self): return self._response(next_state="manual_selection", data=data) def _response_for_set_lot(self, picking, line, message=None): - self._set_lot_from_parse(picking, line) return self._response( next_state="set_lot", data={ @@ -789,38 +788,6 @@ def _response_for_set_lot(self, picking, line, message=None): message=message, ) - def _set_lot_from_parse(self, picking, line): - """ - The lot has not been found in move lines before this call. - - Following the picking type configuration, set it: - - - on lot_id if record is found - - on lot_name if record is not found - - set expiration date if found in parse result - """ - if line.shopfloor_should_create_lot and self.search_result.parse_result: - expiration_date = None - lot_name = None - found = False - for result in self.search_result.parse_result: - if result.type == "lot": - if self.search_result.type == "lot" and self.search_result.record: - lot_id = self.search_result.record - lot_name = lot_id.name - found = True - else: - lot_name = result.value - found = True - if ( - result.type == "expiration_date" - and line.product_id.use_expiration_date - ): - expiration_date = result.value - - if found: - return self.set_lot(picking.id, line.id, lot_name, expiration_date) - def _align_display_product_uom_qty(self, line, response): # This method aligns product uom qties on move lines. # In the shopfloor context, we might have multiple users working at