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..f37e06d0712 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 @@ -946,14 +975,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 +1079,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"), + ) 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