From f287cd4058994539dece24fd8f0fbe65e0358ce2 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 7 Dec 2022 16:36:39 +0100 Subject: [PATCH 01/13] Add shopfloor_gs1 --- shopfloor_gs1/README.rst | 1 + shopfloor_gs1/__init__.py | 1 + shopfloor_gs1/__manifest__.py | 18 +++++ shopfloor_gs1/actions/__init__.py | 1 + shopfloor_gs1/actions/search.py | 28 +++++++ shopfloor_gs1/readme/CONTRIBUTORS.rst | 2 + shopfloor_gs1/readme/DESCRIPTION.rst | 5 ++ shopfloor_gs1/readme/USAGE.rst | 1 + shopfloor_gs1/tests/__init__.py | 1 + .../tests/test_checkout_scan_line.py | 71 ++++++++++++++++ shopfloor_gs1/tests/test_utils.py | 55 +++++++++++++ shopfloor_gs1/utils.py | 80 +++++++++++++++++++ 12 files changed, 264 insertions(+) create mode 100644 shopfloor_gs1/README.rst create mode 100644 shopfloor_gs1/__init__.py create mode 100644 shopfloor_gs1/__manifest__.py create mode 100644 shopfloor_gs1/actions/__init__.py create mode 100644 shopfloor_gs1/actions/search.py create mode 100644 shopfloor_gs1/readme/CONTRIBUTORS.rst create mode 100644 shopfloor_gs1/readme/DESCRIPTION.rst create mode 100644 shopfloor_gs1/readme/USAGE.rst create mode 100644 shopfloor_gs1/tests/__init__.py create mode 100644 shopfloor_gs1/tests/test_checkout_scan_line.py create mode 100644 shopfloor_gs1/tests/test_utils.py create mode 100644 shopfloor_gs1/utils.py diff --git a/shopfloor_gs1/README.rst b/shopfloor_gs1/README.rst new file mode 100644 index 00000000000..7f0885e84e9 --- /dev/null +++ b/shopfloor_gs1/README.rst @@ -0,0 +1 @@ +bot, please! diff --git a/shopfloor_gs1/__init__.py b/shopfloor_gs1/__init__.py new file mode 100644 index 00000000000..f5fe63aaf72 --- /dev/null +++ b/shopfloor_gs1/__init__.py @@ -0,0 +1 @@ +from . import actions diff --git a/shopfloor_gs1/__manifest__.py b/shopfloor_gs1/__manifest__.py new file mode 100644 index 00000000000..fcbe9ba6256 --- /dev/null +++ b/shopfloor_gs1/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2022 Camptocamp SA (http://www.camptocamp.com) +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + "name": "Shopfloor GS1", + "summary": "Integrate GS1 barcode scan into Shopfloor app", + "version": "14.0.1.0.0", + "development_status": "Beta", + "category": "Inventory", + "website": "https://github.com/OCA/wms", + "author": "Camptocamp, Odoo Community Association (OCA)", + "maintainers": ["simahawk", "sebalix"], + "license": "AGPL-3", + "depends": ["shopfloor"], + "external_dependencies": {"python": ["biip"]}, + "data": [], +} diff --git a/shopfloor_gs1/actions/__init__.py b/shopfloor_gs1/actions/__init__.py new file mode 100644 index 00000000000..74d7cf6a341 --- /dev/null +++ b/shopfloor_gs1/actions/__init__.py @@ -0,0 +1 @@ +from . import search diff --git a/shopfloor_gs1/actions/search.py b/shopfloor_gs1/actions/search.py new file mode 100644 index 00000000000..c6f832feee6 --- /dev/null +++ b/shopfloor_gs1/actions/search.py @@ -0,0 +1,28 @@ +# Copyright 2022 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.addons.component.core import Component + +from ..utils import GS1Barcode + + +class SearchAction(Component): + _inherit = "shopfloor.search.action" + + def find(self, barcode, types=None, handler_kw=None): + barcode = barcode or "" + res = self._find_gs1(barcode, types=types) + if res: + return res + return super().find(barcode, types=types, handler_kw=handler_kw) + + # TODO: add tests!!!!!!! + def _find_gs1(self, barcode, types=None, handler_kw=None): + types = types or () + ai_whitelist = [GS1Barcode.to_ai(x) for x in types if GS1Barcode.to_ai(x)] + parsed = GS1Barcode.parse(barcode, ai_whitelist=ai_whitelist) + for item in parsed: + record = self.generic_find( + item.value, types=(item.type,), handler_kw=handler_kw + ) + if record: + return record diff --git a/shopfloor_gs1/readme/CONTRIBUTORS.rst b/shopfloor_gs1/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..8f258ba525c --- /dev/null +++ b/shopfloor_gs1/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Simone Orsi +* Sébastien Alix diff --git a/shopfloor_gs1/readme/DESCRIPTION.rst b/shopfloor_gs1/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..b37a8ddc1a6 --- /dev/null +++ b/shopfloor_gs1/readme/DESCRIPTION.rst @@ -0,0 +1,5 @@ +Add GS1 barcode support to Shopfloor. + +Based on https://biip.readthedocs.io/ + +TODO.... diff --git a/shopfloor_gs1/readme/USAGE.rst b/shopfloor_gs1/readme/USAGE.rst new file mode 100644 index 00000000000..1333ed77b7e --- /dev/null +++ b/shopfloor_gs1/readme/USAGE.rst @@ -0,0 +1 @@ +TODO diff --git a/shopfloor_gs1/tests/__init__.py b/shopfloor_gs1/tests/__init__.py new file mode 100644 index 00000000000..cdb78752c9d --- /dev/null +++ b/shopfloor_gs1/tests/__init__.py @@ -0,0 +1 @@ +from . import test_utils diff --git a/shopfloor_gs1/tests/test_checkout_scan_line.py b/shopfloor_gs1/tests/test_checkout_scan_line.py new file mode 100644 index 00000000000..392168f206f --- /dev/null +++ b/shopfloor_gs1/tests/test_checkout_scan_line.py @@ -0,0 +1,71 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.addons.shopfloor.tests.test_checkout_scan_line_base import ( + CheckoutScanLineCaseBase, +) + +GS1_BARCODE = "(01)09506000117843(11)141231(10)1234AB" +PROD_BARCODE = "09506000117843" +LOT_BARCODE = "1234AB" + +# TODO: we use `search.find` only in checkout.scan_line for now +# but we should test all the other endpoint and scenario as well +# after moving them to `find`. + + +class CheckoutScanLineCase(CheckoutScanLineCaseBase): + def test_scan_line_package_ok(self): + # NOTE: packages GS1 barcode are not supported yet + # -> we test the std behavior + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_b, 10)] + ) + move1 = picking.move_lines[0] + move2 = picking.move_lines[1] + # put the lines in 2 separate packages (only the first line should be selected + # by the package barcode) + self._fill_stock_for_moves(move1, in_package=True) + self._fill_stock_for_moves(move2, in_package=True) + picking.action_assign() + move_line = move1.move_line_ids + self._test_scan_line_ok(move_line.package_id.name, move_line) + + def test_scan_line_product_ok(self): + picking = self._create_picking( + lines=[(self.product_a, 10), (self.product_b, 10)] + ) + # do not put them in a package, we'll pack units here + self._fill_stock_for_moves(picking.move_lines) + picking.action_assign() + line_a = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + # we have 2 different products in the picking, we scan the first + # one and expect to select the line + self.product_a.barcode = PROD_BARCODE + self._test_scan_line_ok(GS1_BARCODE, line_a) + + def test_scan_line_product_lot_ok(self): + picking = self._create_picking( + lines=[(self.product_a, 1), (self.product_a, 1), (self.product_b, 1)] + ) + for move in picking.move_lines: + self._fill_stock_for_moves(move, in_lot=True) + picking.action_assign() + first_line = picking.move_line_ids[0] + lot = first_line.lot_id + lot.name = LOT_BARCODE + self._test_scan_line_ok(GS1_BARCODE, first_line) + + def test_scan_line_product_serial_ok(self): + barcode = "(11)141231(21)1234AB" + picking = self._create_picking( + lines=[(self.product_a, 1), (self.product_a, 1), (self.product_b, 1)] + ) + for move in picking.move_lines: + self._fill_stock_for_moves(move, in_lot=True) + picking.action_assign() + first_line = picking.move_line_ids[0] + lot = first_line.lot_id + lot.name = LOT_BARCODE + self._test_scan_line_ok(barcode, first_line) diff --git a/shopfloor_gs1/tests/test_utils.py b/shopfloor_gs1/tests/test_utils.py new file mode 100644 index 00000000000..a90d75b6d9c --- /dev/null +++ b/shopfloor_gs1/tests/test_utils.py @@ -0,0 +1,55 @@ +# Copyright 2022 Camptocamp +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import datetime + +from odoo.tests.common import BaseCase + +from ..utils import GS1Barcode + + +class TestUtils(BaseCase): + + def test_parse1(self): + code = "(01)09506000117843(11)141231(10)1234AB" + res = GS1Barcode.parse(code) + self.assertEqual(len(res), 3, res) + item = [x for x in res if x.type == "product"][0] + self.assertEqual(item.ai, "01") + self.assertEqual(item.code, code) + self.assertEqual(item.value, "09506000117843") + self.assertEqual(item.raw_value, "09506000117843") + item = [x for x in res if x.type == "production_date"][0] + self.assertEqual(item.ai, "11") + self.assertEqual(item.code, code) + self.assertEqual(item.value, datetime.date(2014, 12, 31)) + self.assertEqual(item.raw_value, "141231") + item = [x for x in res if x.type == "lot"][0] + self.assertEqual(item.ai, "10") + self.assertEqual(item.code, code) + self.assertEqual(item.value, "1234AB") + self.assertEqual(item.raw_value, "1234AB") + + def test_parse2(self): + code = "(01)09506000117843(11)141231(10)1234AB" + res = GS1Barcode.parse(code, ai_whitelist=("01",)) + self.assertEqual(len(res), 1, res) + item = [x for x in res if x.type == "product"][0] + self.assertEqual(item.ai, "01") + self.assertEqual(item.code, code) + self.assertEqual(item.value, "09506000117843") + self.assertEqual(item.raw_value, "09506000117843") + + def test_parse_order(self): + """Ensure ai whitelist order is respected""" + code = "(01)09506000117843(11)141231(10)1234AB" + res = GS1Barcode.parse(code, ai_whitelist=("10","01", "11")) + self.assertEqual(len(res), 3, res) + self.assertEqual(res[0].ai, "10") + self.assertEqual(res[1].ai, "01") + self.assertEqual(res[2].ai, "11") + res = GS1Barcode.parse(code, ai_whitelist=("01","11", "10")) + self.assertEqual(len(res), 3, res) + self.assertEqual(res[0].ai, "01") + self.assertEqual(res[1].ai, "11") + self.assertEqual(res[2].ai, "10") diff --git a/shopfloor_gs1/utils.py b/shopfloor_gs1/utils.py new file mode 100644 index 00000000000..10c15723a66 --- /dev/null +++ b/shopfloor_gs1/utils.py @@ -0,0 +1,80 @@ +# Copyright 2022 Camptocamp SA (http://www.camptocamp.com) +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from biip import ParseError +from biip.gs1 import GS1Message + +AI_MAPPING = { + # https://www.gs1.org/standards/barcodes/application-identifiers + # TODO: define other internal mappings by convention + "01": "product", + "10": "lot", + "11": "production_date", + "21": "serial", +} +AI_MAPPING_INV = {v: k for k, v in AI_MAPPING.items()} + + +class GS1Barcode: + """TODO""" + + __slots__ = ("ai", "type", "code", "value", "raw_value") + + def __init__(self, **kw) -> None: + for k in self.__slots__: + setattr(self, k, kw.get(k)) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}: ai={self.ai} type={self.type}>" + + def __bool__(self): + return self.type != "none" or bool(self.record) + + def __eq__(self, other): + for k in self.__slots__: + if not hasattr(other, k): + return False + if getattr(other, k) != getattr(self, k): + return False + return True + + @classmethod + def parse(cls, barcode, ai_whitelist=None, ai_mapping=None): + """TODO""" + res = [] + try: + # TODO: we might not get an HRI... + parsed = GS1Message.parse_hri(barcode) + except ParseError: + parsed = None + if not parsed: + return res + ai_mapping = ai_mapping or AI_MAPPING + # Use whitelist if given, to respect a specific order + ai_whitelist = ai_whitelist or ai_mapping.keys() + for ai in ai_whitelist: + record_type = ai_mapping[ai] + found = parsed.get(ai=ai) + if found: + # when value is a date the datetime obj is in `date` + # TODO: other types have their own special key + value = found.date or found.value + info = cls( + ai=ai, + type=record_type, + code=barcode, + raw_value=found.value, + value=value, + ) + res.append(info) + return res + + @classmethod + def to_ai(cls, type_, safe=True): + try: + return AI_MAPPING_INV[type_] + except KeyError: + if not safe: + raise ValueError(f"{type_} is not supported.") + return None From 6ca51dd63ce8b99bacbda3eeaf143111d69a110f Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 11 Jan 2023 14:14:48 +0100 Subject: [PATCH 02/13] shopfloor_gs1: pin biip==2.3.0 --- shopfloor_gs1/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor_gs1/__manifest__.py b/shopfloor_gs1/__manifest__.py index fcbe9ba6256..58abc011dbb 100644 --- a/shopfloor_gs1/__manifest__.py +++ b/shopfloor_gs1/__manifest__.py @@ -13,6 +13,6 @@ "maintainers": ["simahawk", "sebalix"], "license": "AGPL-3", "depends": ["shopfloor"], - "external_dependencies": {"python": ["biip"]}, + "external_dependencies": {"python": ["biip==2.3.0"]}, "data": [], } From 9d667d59112a638d7bc8bd09fd6ad7a4705b99d5 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 2 Feb 2023 18:12:27 +0100 Subject: [PATCH 03/13] shopfloor_gs1: refactor mapping handling --- shopfloor_gs1/actions/search.py | 31 ++++++-- shopfloor_gs1/config.py | 26 +++++++ shopfloor_gs1/tests/__init__.py | 1 + shopfloor_gs1/tests/test_action_search.py | 74 +++++++++++++++++++ .../tests/test_checkout_scan_line.py | 71 ------------------ shopfloor_gs1/tests/test_utils.py | 26 ++++--- shopfloor_gs1/utils.py | 45 +++++------ 7 files changed, 160 insertions(+), 114 deletions(-) create mode 100644 shopfloor_gs1/config.py create mode 100644 shopfloor_gs1/tests/test_action_search.py delete mode 100644 shopfloor_gs1/tests/test_checkout_scan_line.py diff --git a/shopfloor_gs1/actions/search.py b/shopfloor_gs1/actions/search.py index c6f832feee6..76d51d7ef5b 100644 --- a/shopfloor_gs1/actions/search.py +++ b/shopfloor_gs1/actions/search.py @@ -2,27 +2,48 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo.addons.component.core import Component +from ..config import MAPPING_AI_TO_TYPE, MAPPING_TYPE_TO_AI from ..utils import GS1Barcode class SearchAction(Component): _inherit = "shopfloor.search.action" + def _search_type_to_gs1_ai(self, _type): + """Convert search type to AIs. + + Each type can be mapped to multiple AIs. + For instance, you can search a product by barcode (01) or manufacturer code (240). + """ + return MAPPING_TYPE_TO_AI.get(_type) + + def _gs1_ai_to_search_type(self, ai): + """Convert back GS1 AI to search type.""" + return MAPPING_AI_TO_TYPE[ai] + def find(self, barcode, types=None, handler_kw=None): barcode = barcode or "" + # Try to find records via GS1 and fallback to normal search res = self._find_gs1(barcode, types=types) if res: return res return super().find(barcode, types=types, handler_kw=handler_kw) - # TODO: add tests!!!!!!! - def _find_gs1(self, barcode, types=None, handler_kw=None): + def _find_gs1(self, barcode, types=None, handler_kw=None, safe=True): types = types or () - ai_whitelist = [GS1Barcode.to_ai(x) for x in types if GS1Barcode.to_ai(x)] - parsed = GS1Barcode.parse(barcode, ai_whitelist=ai_whitelist) + ai_whitelist = () + # Collect all AIs by converting from search types + for _type in types: + ai = self._search_type_to_gs1_ai(_type) + if ai: + ai_whitelist += ai + parsed = GS1Barcode.parse(barcode, ai_whitelist=ai_whitelist, safe=safe) + # Return the 1st record found if parsing was successful for item in parsed: record = self.generic_find( - item.value, types=(item.type,), handler_kw=handler_kw + item.value, + types=(self._gs1_ai_to_search_type(item.ai),), + handler_kw=handler_kw, ) if record: return record diff --git a/shopfloor_gs1/config.py b/shopfloor_gs1/config.py new file mode 100644 index 00000000000..2e03efa7c0a --- /dev/null +++ b/shopfloor_gs1/config.py @@ -0,0 +1,26 @@ +# Copyright 2022 Camptocamp SA (http://www.camptocamp.com) +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +# https://www.gs1.org/standards/barcodes/application-identifiers +# TODO: define other internal mappings by convention + + +# Each type can be mapped to multiple AIs. +# For instance, you can search a product by barcode (01) or manufacturer code (240). +MAPPING_TYPE_TO_AI = { + "product": ("01", "240"), + "lot": ("10",), + "production_date": ("11",), + "serial": ("21",), + "manuf_product_code": ("240",), + "location": ("254",), +} +MAPPING_AI_TO_TYPE = { + "01": "product", + "10": "lot", + "11": "production_date", + "21": "serial", + "240": "product", + "254": "location", +} diff --git a/shopfloor_gs1/tests/__init__.py b/shopfloor_gs1/tests/__init__.py index cdb78752c9d..266b0e51d39 100644 --- a/shopfloor_gs1/tests/__init__.py +++ b/shopfloor_gs1/tests/__init__.py @@ -1 +1,2 @@ from . import test_utils +from . import test_action_search diff --git a/shopfloor_gs1/tests/test_action_search.py b/shopfloor_gs1/tests/test_action_search.py new file mode 100644 index 00000000000..b99a25c2c92 --- /dev/null +++ b/shopfloor_gs1/tests/test_action_search.py @@ -0,0 +1,74 @@ +# Copyright 2022 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.addons.shopfloor.tests.test_actions_search import TestSearchBaseCase + +PROD_BARCODE = "09506000117843" +MANUF_CODE = "K000075" +DATE = "141231" +LOT1 = "1234AB" +LOT2 = "1234AC" +GS1_GTIN_BARCODE_1 = f"(01){PROD_BARCODE}(11){DATE}(10){LOT1}" +GS1_GTIN_BARCODE_2 = f"(01){PROD_BARCODE}(11){DATE}(10){LOT2}" +GS1_MANUF_BARCODE = f"(240){MANUF_CODE}(11){DATE}(10){LOT1}" + + +class TestFind(TestSearchBaseCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.product_a.barcode = PROD_BARCODE + + def test_find_picking(self): + ptype = self.env.ref("shopfloor.picking_type_single_pallet_transfer_demo") + rec = self._create_picking(picking_type=ptype) + res = self.search.find(rec.name, types=("picking",)) + self.assertEqual(res.record, rec) + + def test_find_location(self): + rec = self.customer_location + barcode = GS1_GTIN_BARCODE_1 + "(254)" + rec.name + res = self.search.find(barcode, types=("location",)) + self.assertEqual(res.record, rec) + res = self.search.find(rec.name, types=("location",)) + self.assertEqual(res.record, rec) + + def test_find_package(self): + rec = self.env["stock.quant.package"].sudo().create({"name": "ABC1234"}) + res = self.search.find(rec.name, types=("package",)) + self.assertEqual(res.record, rec) + + def test_find_product(self): + rec = self.product_a + res = self.search.find(GS1_GTIN_BARCODE_1, types=("product",)) + self.assertEqual(res.record, rec) + rec.barcode = MANUF_CODE + res = self.search.find(GS1_MANUF_BARCODE, types=("product",)) + self.assertEqual(res.record, rec) + + def test_find_lot(self): + rec = ( + self.env["stock.production.lot"] + .sudo() + .create( + { + "product_id": self.product_a.id, + "company_id": self.env.company.id, + "name": LOT1, + } + ) + ) + res = self.search.find( + GS1_GTIN_BARCODE_1, + types=("lot",), + handler_kw=dict(lot=dict(products=self.product_a)), + ) + self.assertEqual(res.record, rec) + + def test_find_generic_packaging(self): + rec = ( + self.env["product.packaging"] + .sudo() + .create({"name": "TEST PKG", "barcode": "1234"}) + ) + res = self.search.find(rec.barcode, types=("delivery_packaging",)) + self.assertEqual(res.record, rec) diff --git a/shopfloor_gs1/tests/test_checkout_scan_line.py b/shopfloor_gs1/tests/test_checkout_scan_line.py deleted file mode 100644 index 392168f206f..00000000000 --- a/shopfloor_gs1/tests/test_checkout_scan_line.py +++ /dev/null @@ -1,71 +0,0 @@ -# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo.addons.shopfloor.tests.test_checkout_scan_line_base import ( - CheckoutScanLineCaseBase, -) - -GS1_BARCODE = "(01)09506000117843(11)141231(10)1234AB" -PROD_BARCODE = "09506000117843" -LOT_BARCODE = "1234AB" - -# TODO: we use `search.find` only in checkout.scan_line for now -# but we should test all the other endpoint and scenario as well -# after moving them to `find`. - - -class CheckoutScanLineCase(CheckoutScanLineCaseBase): - def test_scan_line_package_ok(self): - # NOTE: packages GS1 barcode are not supported yet - # -> we test the std behavior - picking = self._create_picking( - lines=[(self.product_a, 10), (self.product_b, 10)] - ) - move1 = picking.move_lines[0] - move2 = picking.move_lines[1] - # put the lines in 2 separate packages (only the first line should be selected - # by the package barcode) - self._fill_stock_for_moves(move1, in_package=True) - self._fill_stock_for_moves(move2, in_package=True) - picking.action_assign() - move_line = move1.move_line_ids - self._test_scan_line_ok(move_line.package_id.name, move_line) - - def test_scan_line_product_ok(self): - picking = self._create_picking( - lines=[(self.product_a, 10), (self.product_b, 10)] - ) - # do not put them in a package, we'll pack units here - self._fill_stock_for_moves(picking.move_lines) - picking.action_assign() - line_a = picking.move_line_ids.filtered( - lambda l: l.product_id == self.product_a - ) - # we have 2 different products in the picking, we scan the first - # one and expect to select the line - self.product_a.barcode = PROD_BARCODE - self._test_scan_line_ok(GS1_BARCODE, line_a) - - def test_scan_line_product_lot_ok(self): - picking = self._create_picking( - lines=[(self.product_a, 1), (self.product_a, 1), (self.product_b, 1)] - ) - for move in picking.move_lines: - self._fill_stock_for_moves(move, in_lot=True) - picking.action_assign() - first_line = picking.move_line_ids[0] - lot = first_line.lot_id - lot.name = LOT_BARCODE - self._test_scan_line_ok(GS1_BARCODE, first_line) - - def test_scan_line_product_serial_ok(self): - barcode = "(11)141231(21)1234AB" - picking = self._create_picking( - lines=[(self.product_a, 1), (self.product_a, 1), (self.product_b, 1)] - ) - for move in picking.move_lines: - self._fill_stock_for_moves(move, in_lot=True) - picking.action_assign() - first_line = picking.move_line_ids[0] - lot = first_line.lot_id - lot.name = LOT_BARCODE - self._test_scan_line_ok(barcode, first_line) diff --git a/shopfloor_gs1/tests/test_utils.py b/shopfloor_gs1/tests/test_utils.py index a90d75b6d9c..7ba0935f323 100644 --- a/shopfloor_gs1/tests/test_utils.py +++ b/shopfloor_gs1/tests/test_utils.py @@ -9,23 +9,19 @@ class TestUtils(BaseCase): - def test_parse1(self): code = "(01)09506000117843(11)141231(10)1234AB" res = GS1Barcode.parse(code) self.assertEqual(len(res), 3, res) - item = [x for x in res if x.type == "product"][0] - self.assertEqual(item.ai, "01") + item = [x for x in res if x.ai == "01"][0] self.assertEqual(item.code, code) self.assertEqual(item.value, "09506000117843") self.assertEqual(item.raw_value, "09506000117843") - item = [x for x in res if x.type == "production_date"][0] - self.assertEqual(item.ai, "11") + item = [x for x in res if x.ai == "11"][0] self.assertEqual(item.code, code) self.assertEqual(item.value, datetime.date(2014, 12, 31)) self.assertEqual(item.raw_value, "141231") - item = [x for x in res if x.type == "lot"][0] - self.assertEqual(item.ai, "10") + item = [x for x in res if x.ai == "10"][0] self.assertEqual(item.code, code) self.assertEqual(item.value, "1234AB") self.assertEqual(item.raw_value, "1234AB") @@ -34,21 +30,29 @@ def test_parse2(self): code = "(01)09506000117843(11)141231(10)1234AB" res = GS1Barcode.parse(code, ai_whitelist=("01",)) self.assertEqual(len(res), 1, res) - item = [x for x in res if x.type == "product"][0] - self.assertEqual(item.ai, "01") + item = [x for x in res if x.ai == "01"][0] self.assertEqual(item.code, code) self.assertEqual(item.value, "09506000117843") self.assertEqual(item.raw_value, "09506000117843") + def test_parse3(self): + code = "(240)K000075(11)230201(10)0000392" + res = GS1Barcode.parse(code, ai_whitelist=("240",)) + self.assertEqual(len(res), 1, res) + item = [x for x in res if x.ai == "240"][0] + self.assertEqual(item.code, code) + self.assertEqual(item.value, "K000075") + self.assertEqual(item.raw_value, "K000075") + def test_parse_order(self): """Ensure ai whitelist order is respected""" code = "(01)09506000117843(11)141231(10)1234AB" - res = GS1Barcode.parse(code, ai_whitelist=("10","01", "11")) + res = GS1Barcode.parse(code, ai_whitelist=("10", "01", "11")) self.assertEqual(len(res), 3, res) self.assertEqual(res[0].ai, "10") self.assertEqual(res[1].ai, "01") self.assertEqual(res[2].ai, "11") - res = GS1Barcode.parse(code, ai_whitelist=("01","11", "10")) + res = GS1Barcode.parse(code, ai_whitelist=("01", "11", "10")) self.assertEqual(len(res), 3, res) self.assertEqual(res[0].ai, "01") self.assertEqual(res[1].ai, "11") diff --git a/shopfloor_gs1/utils.py b/shopfloor_gs1/utils.py index 10c15723a66..04e52800896 100644 --- a/shopfloor_gs1/utils.py +++ b/shopfloor_gs1/utils.py @@ -5,28 +5,22 @@ from biip import ParseError from biip.gs1 import GS1Message -AI_MAPPING = { - # https://www.gs1.org/standards/barcodes/application-identifiers - # TODO: define other internal mappings by convention - "01": "product", - "10": "lot", - "11": "production_date", - "21": "serial", -} -AI_MAPPING_INV = {v: k for k, v in AI_MAPPING.items()} +from .config import MAPPING_AI_TO_TYPE + +DEFAULT_AI_WHITELIST = tuple(MAPPING_AI_TO_TYPE.keys()) class GS1Barcode: - """TODO""" + """GS1 barcode parser and wrapper.""" - __slots__ = ("ai", "type", "code", "value", "raw_value") + __slots__ = ("ai", "code", "value", "raw_value") def __init__(self, **kw) -> None: for k in self.__slots__: setattr(self, k, kw.get(k)) def __repr__(self) -> str: - return f"<{self.__class__.__name__}: ai={self.ai} type={self.type}>" + return f"<{self.__class__.__name__}: ai={self.ai}>" def __bool__(self): return self.type != "none" or bool(self.record) @@ -40,21 +34,28 @@ def __eq__(self, other): return True @classmethod - def parse(cls, barcode, ai_whitelist=None, ai_mapping=None): - """TODO""" + def parse(cls, barcode, ai_whitelist=None, safe=True): + """Parse given barcode + + :param barcode: valid GS1 barcode + :param ai_whitelist: ordered list of AI to look for + :param safe: break or not if barcode is invalid + + :return: an instance of `GS1Barcode`. + """ res = [] try: # TODO: we might not get an HRI... parsed = GS1Message.parse_hri(barcode) except ParseError: + if not safe: + raise parsed = None if not parsed: return res - ai_mapping = ai_mapping or AI_MAPPING # Use whitelist if given, to respect a specific order - ai_whitelist = ai_whitelist or ai_mapping.keys() + ai_whitelist = ai_whitelist or DEFAULT_AI_WHITELIST for ai in ai_whitelist: - record_type = ai_mapping[ai] found = parsed.get(ai=ai) if found: # when value is a date the datetime obj is in `date` @@ -62,19 +63,9 @@ def parse(cls, barcode, ai_whitelist=None, ai_mapping=None): value = found.date or found.value info = cls( ai=ai, - type=record_type, code=barcode, raw_value=found.value, value=value, ) res.append(info) return res - - @classmethod - def to_ai(cls, type_, safe=True): - try: - return AI_MAPPING_INV[type_] - except KeyError: - if not safe: - raise ValueError(f"{type_} is not supported.") - return None From 0abaf5bbf022e972a3c54ac7c6fb5746029e2af0 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 23 Feb 2023 13:56:09 +0100 Subject: [PATCH 04/13] shopfloor_gs1: add tests for scan_anything --- shopfloor_gs1/tests/__init__.py | 1 + shopfloor_gs1/tests/common.py | 8 +++ shopfloor_gs1/tests/test_action_search.py | 15 +++-- shopfloor_gs1/tests/test_scan_anything.py | 71 +++++++++++++++++++++++ 4 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 shopfloor_gs1/tests/common.py create mode 100644 shopfloor_gs1/tests/test_scan_anything.py diff --git a/shopfloor_gs1/tests/__init__.py b/shopfloor_gs1/tests/__init__.py index 266b0e51d39..412f8593b8b 100644 --- a/shopfloor_gs1/tests/__init__.py +++ b/shopfloor_gs1/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_utils from . import test_action_search +from . import test_scan_anything diff --git a/shopfloor_gs1/tests/common.py b/shopfloor_gs1/tests/common.py new file mode 100644 index 00000000000..ae5367c87a3 --- /dev/null +++ b/shopfloor_gs1/tests/common.py @@ -0,0 +1,8 @@ +PROD_BARCODE = "09506000117843" +MANUF_CODE = "K000075" +DATE = "141231" +LOT1 = "1234AB" +LOT2 = "1234AC" +GS1_GTIN_BARCODE_1 = f"(01){PROD_BARCODE}(11){DATE}(10){LOT1}" +GS1_GTIN_BARCODE_2 = f"(01){PROD_BARCODE}(11){DATE}(10){LOT2}" +GS1_MANUF_BARCODE = f"(240){MANUF_CODE}(11){DATE}(10){LOT1}" diff --git a/shopfloor_gs1/tests/test_action_search.py b/shopfloor_gs1/tests/test_action_search.py index b99a25c2c92..fd5ec6e942d 100644 --- a/shopfloor_gs1/tests/test_action_search.py +++ b/shopfloor_gs1/tests/test_action_search.py @@ -2,14 +2,13 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo.addons.shopfloor.tests.test_actions_search import TestSearchBaseCase -PROD_BARCODE = "09506000117843" -MANUF_CODE = "K000075" -DATE = "141231" -LOT1 = "1234AB" -LOT2 = "1234AC" -GS1_GTIN_BARCODE_1 = f"(01){PROD_BARCODE}(11){DATE}(10){LOT1}" -GS1_GTIN_BARCODE_2 = f"(01){PROD_BARCODE}(11){DATE}(10){LOT2}" -GS1_MANUF_BARCODE = f"(240){MANUF_CODE}(11){DATE}(10){LOT1}" +from .common import ( + GS1_GTIN_BARCODE_1, + GS1_MANUF_BARCODE, + LOT1, + MANUF_CODE, + PROD_BARCODE, +) class TestFind(TestSearchBaseCase): diff --git a/shopfloor_gs1/tests/test_scan_anything.py b/shopfloor_gs1/tests/test_scan_anything.py new file mode 100644 index 00000000000..7b9660c0cc6 --- /dev/null +++ b/shopfloor_gs1/tests/test_scan_anything.py @@ -0,0 +1,71 @@ +# Copyright 2023 Camptocamp SA (http://www.camptocamp.com) +# @author Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.addons.shopfloor.tests.test_actions_data_base import ActionsDataDetailCaseBase +from odoo.addons.shopfloor_base.tests.common_misc import ScanAnythingTestMixin + +from .common import ( + GS1_GTIN_BARCODE_1, + GS1_MANUF_BARCODE, + LOT1, + MANUF_CODE, + PROD_BARCODE, +) + + +class ScanAnythingCase(ActionsDataDetailCaseBase, ScanAnythingTestMixin): + def test_scan_product(self): + record = self.product_b + record.barcode = PROD_BARCODE + record.default_code = MANUF_CODE + rec_type = "product" + data = self.data_detail.product_detail(record) + # All kinds of search supported + for identifier in ( + GS1_GTIN_BARCODE_1, + GS1_MANUF_BARCODE, + record.barcode, + record.default_code, + ): + self._test_response_ok(rec_type, data, identifier) + + def test_find_location(self): + record = self.stock_location + rec_type = "location" + gs1_barcode = GS1_GTIN_BARCODE_1 + "(254)" + record.name + data = self.data_detail.location_detail(record) + for identifier in (gs1_barcode, record.name): + self._test_response_ok(rec_type, data, identifier) + + def test_scan_package(self): + record = self.package + rec_type = "package" + identifier = record.name + data = self.data_detail.package_detail(record) + self._test_response_ok(rec_type, data, identifier) + + def test_scan_lot(self): + record = ( + self.env["stock.production.lot"] + .sudo() + .create( + { + "product_id": self.product_a.id, + "company_id": self.env.company.id, + "name": LOT1, + } + ) + ) + rec_type = "lot" + identifier = record.name + data = self.data_detail.lot_detail(record) + for identifier in (GS1_GTIN_BARCODE_1, record.name): + self._test_response_ok(rec_type, data, identifier) + + def test_scan_transfer(self): + record = self.picking + rec_type = "transfer" + identifier = record.name + data = self.data_detail.picking_detail(record) + self._test_response_ok(rec_type, data, identifier) From 4bf0a20a09600951b477bea2d6211fc9c2b84d47 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 23 Feb 2023 14:01:16 +0100 Subject: [PATCH 05/13] shopfloor_gs1: skip search if no AI found If you are specifically searching for a type and this type is not supported by our AI mapping, just don't search for it. This is kind of mandatory because otherwise the GS1 parsing will default to all available AIs and can give back unexpected results (eg: search for 'package' and get an empty 'product' back) --- shopfloor_gs1/actions/search.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shopfloor_gs1/actions/search.py b/shopfloor_gs1/actions/search.py index 76d51d7ef5b..c390cae37dd 100644 --- a/shopfloor_gs1/actions/search.py +++ b/shopfloor_gs1/actions/search.py @@ -37,6 +37,9 @@ def _find_gs1(self, barcode, types=None, handler_kw=None, safe=True): ai = self._search_type_to_gs1_ai(_type) if ai: ai_whitelist += ai + if types and not ai_whitelist: + # A specific type was asked but no AI could be found. + return parsed = GS1Barcode.parse(barcode, ai_whitelist=ai_whitelist, safe=safe) # Return the 1st record found if parsing was successful for item in parsed: From 3c9b18c9ff13c2fa9e9ad385511ead16a5b768e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 8 Aug 2023 11:08:22 +0200 Subject: [PATCH 06/13] fixup! shopfloor_gs1: pin biip==2.3.0 --- shopfloor_gs1/__manifest__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/shopfloor_gs1/__manifest__.py b/shopfloor_gs1/__manifest__.py index 58abc011dbb..86a3cdbaea2 100644 --- a/shopfloor_gs1/__manifest__.py +++ b/shopfloor_gs1/__manifest__.py @@ -13,6 +13,13 @@ "maintainers": ["simahawk", "sebalix"], "license": "AGPL-3", "depends": ["shopfloor"], - "external_dependencies": {"python": ["biip==2.3.0"]}, + "external_dependencies": { + "python": [ + # >= 2.3.0 required to use 'GS1Message.parse_hri' method + # and next version 3.0.0 has been refactored bringing + # incompatibility issues (to check later). + "biip==2.3.0" + ] + }, "data": [], } From 93b38af2721c134145d1c799fb66790219f9870c Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Mon, 16 Dec 2024 17:07:58 +0100 Subject: [PATCH 07/13] [IMP] shopfloor_gs1: pre-commit stuff --- requirements.txt | 1 + setup/shopfloor_gs1/odoo/addons/shopfloor_gs1 | 1 + setup/shopfloor_gs1/setup.py | 6 + shopfloor_gs1/README.rst | 98 +++- shopfloor_gs1/static/description/index.html | 433 ++++++++++++++++++ 5 files changed, 538 insertions(+), 1 deletion(-) create mode 120000 setup/shopfloor_gs1/odoo/addons/shopfloor_gs1 create mode 100644 setup/shopfloor_gs1/setup.py create mode 100644 shopfloor_gs1/static/description/index.html diff --git a/requirements.txt b/requirements.txt index 180fc49789b..ec5d966bb66 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ # generated from manifests external_dependencies +biip==2.3.0 openupgradelib diff --git a/setup/shopfloor_gs1/odoo/addons/shopfloor_gs1 b/setup/shopfloor_gs1/odoo/addons/shopfloor_gs1 new file mode 120000 index 00000000000..59b110fbbb3 --- /dev/null +++ b/setup/shopfloor_gs1/odoo/addons/shopfloor_gs1 @@ -0,0 +1 @@ +../../../../shopfloor_gs1 \ No newline at end of file diff --git a/setup/shopfloor_gs1/setup.py b/setup/shopfloor_gs1/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/shopfloor_gs1/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/shopfloor_gs1/README.rst b/shopfloor_gs1/README.rst index 7f0885e84e9..097cd077e21 100644 --- a/shopfloor_gs1/README.rst +++ b/shopfloor_gs1/README.rst @@ -1 +1,97 @@ -bot, please! +============= +Shopfloor GS1 +============= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:15ac24e92ed1dc3217969411ccf2ffaba7bd0a61c8eabadcab6cdb06030189cd + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fwms-lightgray.png?logo=github + :target: https://github.com/OCA/wms/tree/16.0/shopfloor_gs1 + :alt: OCA/wms +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/wms-16-0/wms-16-0-shopfloor_gs1 + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/wms&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Add GS1 barcode support to Shopfloor. + +Based on https://biip.readthedocs.io/ + +TODO.... + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +TODO + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +* Simone Orsi +* Sébastien Alix + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-simahawk| image:: https://github.com/simahawk.png?size=40px + :target: https://github.com/simahawk + :alt: simahawk +.. |maintainer-sebalix| image:: https://github.com/sebalix.png?size=40px + :target: https://github.com/sebalix + :alt: sebalix + +Current `maintainers `__: + +|maintainer-simahawk| |maintainer-sebalix| + +This module is part of the `OCA/wms `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/shopfloor_gs1/static/description/index.html b/shopfloor_gs1/static/description/index.html new file mode 100644 index 00000000000..2fcd78af4ba --- /dev/null +++ b/shopfloor_gs1/static/description/index.html @@ -0,0 +1,433 @@ + + + + + +Shopfloor GS1 + + + +
+

Shopfloor GS1

+ + +

Beta License: AGPL-3 OCA/wms Translate me on Weblate Try me on Runboat

+

Add GS1 barcode support to Shopfloor.

+

Based on https://biip.readthedocs.io/

+

TODO….

+

Table of contents

+ +
+

Usage

+

TODO

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainers:

+

simahawk sebalix

+

This module is part of the OCA/wms project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + From 969a54a49cbe03273f0a68b2cff181aa9efb3ca9 Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Mon, 6 Jan 2025 09:43:33 +0100 Subject: [PATCH 08/13] [MIG][16.0] shopfloor_gs1 --- requirements.txt | 2 +- shopfloor_gs1/README.rst | 6 +- shopfloor_gs1/__manifest__.py | 5 +- shopfloor_gs1/readme/CONTRIBUTORS.rst | 1 + shopfloor_gs1/readme/DESCRIPTION.rst | 3 +- shopfloor_gs1/readme/USAGE.rst | 2 +- shopfloor_gs1/static/description/index.html | 8 ++- shopfloor_gs1/tests/__init__.py | 1 + shopfloor_gs1/tests/common.py | 10 ++- shopfloor_gs1/tests/test_action_search.py | 14 +--- shopfloor_gs1/tests/test_action_search_hri.py | 65 +++++++++++++++++++ shopfloor_gs1/tests/test_scan_anything.py | 4 +- shopfloor_gs1/tests/test_utils.py | 15 +++++ shopfloor_gs1/utils.py | 13 ++-- 14 files changed, 118 insertions(+), 31 deletions(-) create mode 100644 shopfloor_gs1/tests/test_action_search_hri.py diff --git a/requirements.txt b/requirements.txt index ec5d966bb66..7a08bafecc0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ # generated from manifests external_dependencies -biip==2.3.0 +biip openupgradelib diff --git a/shopfloor_gs1/README.rst b/shopfloor_gs1/README.rst index 097cd077e21..bd5fa28e307 100644 --- a/shopfloor_gs1/README.rst +++ b/shopfloor_gs1/README.rst @@ -32,7 +32,8 @@ Add GS1 barcode support to Shopfloor. Based on https://biip.readthedocs.io/ -TODO.... +This module allows to use the biip library to interpret a scanned GS1 barcode +and return the corresponding Odoo record for Shopfloor `find` method. **Table of contents** @@ -42,7 +43,7 @@ TODO.... Usage ===== -TODO +- You can use the `Scan` action in Shopfloor with a GS1 barcode. The Bug Tracker =========== @@ -67,6 +68,7 @@ Contributors * Simone Orsi * Sébastien Alix +* Denis Roussel Maintainers ~~~~~~~~~~~ diff --git a/shopfloor_gs1/__manifest__.py b/shopfloor_gs1/__manifest__.py index 86a3cdbaea2..4656e22d884 100644 --- a/shopfloor_gs1/__manifest__.py +++ b/shopfloor_gs1/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Shopfloor GS1", "summary": "Integrate GS1 barcode scan into Shopfloor app", - "version": "14.0.1.0.0", + "version": "16.0.1.0.0", "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", @@ -18,8 +18,7 @@ # >= 2.3.0 required to use 'GS1Message.parse_hri' method # and next version 3.0.0 has been refactored bringing # incompatibility issues (to check later). - "biip==2.3.0" + "biip" ] }, - "data": [], } diff --git a/shopfloor_gs1/readme/CONTRIBUTORS.rst b/shopfloor_gs1/readme/CONTRIBUTORS.rst index 8f258ba525c..2dfc355afef 100644 --- a/shopfloor_gs1/readme/CONTRIBUTORS.rst +++ b/shopfloor_gs1/readme/CONTRIBUTORS.rst @@ -1,2 +1,3 @@ * Simone Orsi * Sébastien Alix +* Denis Roussel diff --git a/shopfloor_gs1/readme/DESCRIPTION.rst b/shopfloor_gs1/readme/DESCRIPTION.rst index b37a8ddc1a6..849abae8cb1 100644 --- a/shopfloor_gs1/readme/DESCRIPTION.rst +++ b/shopfloor_gs1/readme/DESCRIPTION.rst @@ -2,4 +2,5 @@ Add GS1 barcode support to Shopfloor. Based on https://biip.readthedocs.io/ -TODO.... +This module allows to use the biip library to interpret a scanned GS1 barcode +and return the corresponding Odoo record for Shopfloor `find` method. diff --git a/shopfloor_gs1/readme/USAGE.rst b/shopfloor_gs1/readme/USAGE.rst index 1333ed77b7e..6da03b44815 100644 --- a/shopfloor_gs1/readme/USAGE.rst +++ b/shopfloor_gs1/readme/USAGE.rst @@ -1 +1 @@ -TODO +- You can use the `Scan` action in Shopfloor with a GS1 barcode. The \ No newline at end of file diff --git a/shopfloor_gs1/static/description/index.html b/shopfloor_gs1/static/description/index.html index 2fcd78af4ba..7b35c4df3cd 100644 --- a/shopfloor_gs1/static/description/index.html +++ b/shopfloor_gs1/static/description/index.html @@ -372,7 +372,8 @@

Shopfloor GS1

Beta License: AGPL-3 OCA/wms Translate me on Weblate Try me on Runboat

Add GS1 barcode support to Shopfloor.

Based on https://biip.readthedocs.io/

-

TODO….

+

This module allows to use the biip library to interpret a scanned GS1 barcode +and return the corresponding Odoo record for Shopfloor find method.

Table of contents

    @@ -388,7 +389,9 @@

    Shopfloor GS1

Usage

-

TODO

+
    +
  • You can use the Scan action in Shopfloor with a GS1 barcode. The
  • +

Bug Tracker

@@ -411,6 +414,7 @@

Contributors

diff --git a/shopfloor_gs1/tests/__init__.py b/shopfloor_gs1/tests/__init__.py index 412f8593b8b..cd472a1a253 100644 --- a/shopfloor_gs1/tests/__init__.py +++ b/shopfloor_gs1/tests/__init__.py @@ -1,3 +1,4 @@ from . import test_utils from . import test_action_search from . import test_scan_anything +from . import test_action_search_hri diff --git a/shopfloor_gs1/tests/common.py b/shopfloor_gs1/tests/common.py index ae5367c87a3..34617e081a3 100644 --- a/shopfloor_gs1/tests/common.py +++ b/shopfloor_gs1/tests/common.py @@ -3,6 +3,10 @@ DATE = "141231" LOT1 = "1234AB" LOT2 = "1234AC" -GS1_GTIN_BARCODE_1 = f"(01){PROD_BARCODE}(11){DATE}(10){LOT1}" -GS1_GTIN_BARCODE_2 = f"(01){PROD_BARCODE}(11){DATE}(10){LOT2}" -GS1_MANUF_BARCODE = f"(240){MANUF_CODE}(11){DATE}(10){LOT1}" +GS1_GTIN_BARCODE_1_HRI = f"(01){PROD_BARCODE}(11){DATE}(10){LOT1}" +GS1_GTIN_BARCODE_2_HRI = f"(01){PROD_BARCODE}(11){DATE}(10){LOT2}" +GS1_MANUF_BARCODE_HRI = f"(240){MANUF_CODE}(11){DATE}(10){LOT1}" + +GS1_GTIN_BARCODE_1 = f"01{PROD_BARCODE}11{DATE}10{LOT1}" +GS1_GTIN_BARCODE_2 = f"01{PROD_BARCODE}11{DATE}10{LOT2}" +GS1_MANUF_BARCODE = f"01{PROD_BARCODE}11{DATE}10{LOT1}\x1d240{MANUF_CODE}" diff --git a/shopfloor_gs1/tests/test_action_search.py b/shopfloor_gs1/tests/test_action_search.py index fd5ec6e942d..be659c14bd9 100644 --- a/shopfloor_gs1/tests/test_action_search.py +++ b/shopfloor_gs1/tests/test_action_search.py @@ -14,6 +14,7 @@ class TestFind(TestSearchBaseCase): @classmethod def setUpClassBaseData(cls): + # pylint: disable=missing-return super().setUpClassBaseData() cls.product_a.barcode = PROD_BARCODE @@ -25,7 +26,7 @@ def test_find_picking(self): def test_find_location(self): rec = self.customer_location - barcode = GS1_GTIN_BARCODE_1 + "(254)" + rec.name + barcode = GS1_GTIN_BARCODE_1 + "\x1d254" + rec.name res = self.search.find(barcode, types=("location",)) self.assertEqual(res.record, rec) res = self.search.find(rec.name, types=("location",)) @@ -46,7 +47,7 @@ def test_find_product(self): def test_find_lot(self): rec = ( - self.env["stock.production.lot"] + self.env["stock.lot"] .sudo() .create( { @@ -62,12 +63,3 @@ def test_find_lot(self): handler_kw=dict(lot=dict(products=self.product_a)), ) self.assertEqual(res.record, rec) - - def test_find_generic_packaging(self): - rec = ( - self.env["product.packaging"] - .sudo() - .create({"name": "TEST PKG", "barcode": "1234"}) - ) - res = self.search.find(rec.barcode, types=("delivery_packaging",)) - self.assertEqual(res.record, rec) diff --git a/shopfloor_gs1/tests/test_action_search_hri.py b/shopfloor_gs1/tests/test_action_search_hri.py new file mode 100644 index 00000000000..e9fa3a884c8 --- /dev/null +++ b/shopfloor_gs1/tests/test_action_search_hri.py @@ -0,0 +1,65 @@ +# Copyright 2022 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.addons.shopfloor.tests.test_actions_search import TestSearchBaseCase + +from .common import ( + GS1_GTIN_BARCODE_1_HRI, + GS1_MANUF_BARCODE_HRI, + LOT1, + MANUF_CODE, + PROD_BARCODE, +) + + +class TestFindHri(TestSearchBaseCase): + @classmethod + def setUpClassBaseData(cls): + # pylint: disable=missing-return + super().setUpClassBaseData() + cls.product_a.barcode = PROD_BARCODE + + def test_find_picking(self): + ptype = self.env.ref("shopfloor.picking_type_single_pallet_transfer_demo") + rec = self._create_picking(picking_type=ptype) + res = self.search.find(rec.name, types=("picking",)) + self.assertEqual(res.record, rec) + + def test_find_location(self): + rec = self.customer_location + barcode = GS1_GTIN_BARCODE_1_HRI + "(254)" + rec.name + res = self.search.find(barcode, types=("location",)) + self.assertEqual(res.record, rec) + res = self.search.find(rec.name, types=("location",)) + self.assertEqual(res.record, rec) + + def test_find_package(self): + rec = self.env["stock.quant.package"].sudo().create({"name": "ABC1234"}) + res = self.search.find(rec.name, types=("package",)) + self.assertEqual(res.record, rec) + + def test_find_product(self): + rec = self.product_a + res = self.search.find(GS1_GTIN_BARCODE_1_HRI, types=("product",)) + self.assertEqual(res.record, rec) + rec.barcode = MANUF_CODE + res = self.search.find(GS1_MANUF_BARCODE_HRI, types=("product",)) + self.assertEqual(res.record, rec) + + def test_find_lot(self): + rec = ( + self.env["stock.lot"] + .sudo() + .create( + { + "product_id": self.product_a.id, + "company_id": self.env.company.id, + "name": LOT1, + } + ) + ) + res = self.search.find( + GS1_GTIN_BARCODE_1_HRI, + types=("lot",), + handler_kw=dict(lot=dict(products=self.product_a)), + ) + self.assertEqual(res.record, rec) diff --git a/shopfloor_gs1/tests/test_scan_anything.py b/shopfloor_gs1/tests/test_scan_anything.py index 7b9660c0cc6..17f88997bb4 100644 --- a/shopfloor_gs1/tests/test_scan_anything.py +++ b/shopfloor_gs1/tests/test_scan_anything.py @@ -33,7 +33,7 @@ def test_scan_product(self): def test_find_location(self): record = self.stock_location rec_type = "location" - gs1_barcode = GS1_GTIN_BARCODE_1 + "(254)" + record.name + gs1_barcode = GS1_GTIN_BARCODE_1 + "\x1d254" + record.name data = self.data_detail.location_detail(record) for identifier in (gs1_barcode, record.name): self._test_response_ok(rec_type, data, identifier) @@ -47,7 +47,7 @@ def test_scan_package(self): def test_scan_lot(self): record = ( - self.env["stock.production.lot"] + self.env["stock.lot"] .sudo() .create( { diff --git a/shopfloor_gs1/tests/test_utils.py b/shopfloor_gs1/tests/test_utils.py index 7ba0935f323..d5c85e763ba 100644 --- a/shopfloor_gs1/tests/test_utils.py +++ b/shopfloor_gs1/tests/test_utils.py @@ -57,3 +57,18 @@ def test_parse_order(self): self.assertEqual(res[0].ai, "01") self.assertEqual(res[1].ai, "11") self.assertEqual(res[2].ai, "10") + + def test_barcode(self): + code = "(01)09506000117843(11)141231(10)1234AB" + res = GS1Barcode.parse(code) + res_2 = GS1Barcode.parse(code) + + self.assertTrue(res[0] == res_2[0]) + + self.assertFalse(res[0] == res_2[1]) + self.assertFalse(res[0] == list()) + + self.assertTrue(res[0]) + self.assertFalse(GS1Barcode()) + + self.assertEqual(str(res[0]), "") diff --git a/shopfloor_gs1/utils.py b/shopfloor_gs1/utils.py index 04e52800896..caca5ac8cd2 100644 --- a/shopfloor_gs1/utils.py +++ b/shopfloor_gs1/utils.py @@ -23,7 +23,7 @@ def __repr__(self) -> str: return f"<{self.__class__.__name__}: ai={self.ai}>" def __bool__(self): - return self.type != "none" or bool(self.record) + return bool(self.ai) def __eq__(self, other): for k in self.__slots__: @@ -41,16 +41,19 @@ def parse(cls, barcode, ai_whitelist=None, safe=True): :param ai_whitelist: ordered list of AI to look for :param safe: break or not if barcode is invalid - :return: an instance of `GS1Barcode`. + :return: a list of `GS1Barcode` instances. """ res = [] try: # TODO: we might not get an HRI... parsed = GS1Message.parse_hri(barcode) except ParseError: - if not safe: - raise - parsed = None + try: + parsed = GS1Message.parse(barcode) + except ParseError: + if not safe: + raise + parsed = None if not parsed: return res # Use whitelist if given, to respect a specific order From a612edd1a6370827be8a8bf83779a447fc130086 Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Thu, 16 Jan 2025 11:50:14 +0100 Subject: [PATCH 09/13] [IMP] shopfloor_gs1: Add a parser for GS1 / Add expiration date management --- shopfloor_gs1/actions/search.py | 58 +++++++++++++++++++++------------ shopfloor_gs1/config.py | 2 ++ shopfloor_gs1/utils.py | 20 ++++++++++-- 3 files changed, 56 insertions(+), 24 deletions(-) diff --git a/shopfloor_gs1/actions/search.py b/shopfloor_gs1/actions/search.py index c390cae37dd..f8a7c5c9b6a 100644 --- a/shopfloor_gs1/actions/search.py +++ b/shopfloor_gs1/actions/search.py @@ -1,13 +1,19 @@ # Copyright 2022 Camptocamp SA (http://www.camptocamp.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + from odoo.addons.component.core import Component +from odoo.addons.shopfloor.actions.barcode_parser import BarcodeResult from ..config import MAPPING_AI_TO_TYPE, MAPPING_TYPE_TO_AI from ..utils import GS1Barcode -class SearchAction(Component): - _inherit = "shopfloor.search.action" +class BarcodeParser(Component): + """ + Some barcodes can have complex data structure + """ + + _inherit = "shopfloor.barcode.parser" def _search_type_to_gs1_ai(self, _type): """Convert search type to AIs. @@ -21,15 +27,7 @@ def _gs1_ai_to_search_type(self, ai): """Convert back GS1 AI to search type.""" return MAPPING_AI_TO_TYPE[ai] - def find(self, barcode, types=None, handler_kw=None): - barcode = barcode or "" - # Try to find records via GS1 and fallback to normal search - res = self._find_gs1(barcode, types=types) - if res: - return res - return super().find(barcode, types=types, handler_kw=handler_kw) - - def _find_gs1(self, barcode, types=None, handler_kw=None, safe=True): + def _parse_gs1(self, barcode, types, safe=True): types = types or () ai_whitelist = () # Collect all AIs by converting from search types @@ -39,14 +37,32 @@ def _find_gs1(self, barcode, types=None, handler_kw=None, safe=True): ai_whitelist += ai if types and not ai_whitelist: # A specific type was asked but no AI could be found. - return + return None, None parsed = GS1Barcode.parse(barcode, ai_whitelist=ai_whitelist, safe=safe) - # Return the 1st record found if parsing was successful - for item in parsed: - record = self.generic_find( - item.value, - types=(self._gs1_ai_to_search_type(item.ai),), - handler_kw=handler_kw, - ) - if record: - return record + return parsed + + def parse(self, barcode, types): + """ + This method will parse the barcode and return the + value with its type if determined. + + Override this to implement specific parsing + + """ + # search = self._actions_for("search") + parsed = self._parse_gs1(barcode, types) + if parsed: + result = [] + for barcode_type in self.search_action._barcode_type_handler.keys(): + for parsed_item in parsed: + if parsed_item.ai in MAPPING_TYPE_TO_AI.get(barcode_type, tuple()): + result.append( + BarcodeResult( + type=barcode_type, + value=parsed_item.value, + raw=parsed_item.raw_value, + ) + ) + if result: + return result + return super().parse(barcode, types) diff --git a/shopfloor_gs1/config.py b/shopfloor_gs1/config.py index 2e03efa7c0a..4f3f924d1d4 100644 --- a/shopfloor_gs1/config.py +++ b/shopfloor_gs1/config.py @@ -12,6 +12,7 @@ "product": ("01", "240"), "lot": ("10",), "production_date": ("11",), + "expiration_date": ("17",), "serial": ("21",), "manuf_product_code": ("240",), "location": ("254",), @@ -20,6 +21,7 @@ "01": "product", "10": "lot", "11": "production_date", + "17": "expiration_date", "21": "serial", "240": "product", "254": "location", diff --git a/shopfloor_gs1/utils.py b/shopfloor_gs1/utils.py index caca5ac8cd2..0238ee594c6 100644 --- a/shopfloor_gs1/utils.py +++ b/shopfloor_gs1/utils.py @@ -3,7 +3,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from biip import ParseError -from biip.gs1 import GS1Message +from biip.gs1 import GS1ElementString, GS1Message from .config import MAPPING_AI_TO_TYPE @@ -33,12 +33,26 @@ def __eq__(self, other): return False return True + @classmethod + def _get_value(cls, ai_value: GS1ElementString): + """ + We must return the date value if set + We must return the formatted GTIN if set + as the library will transform the GTIN 14 into + a GTIN 13 one. That one is the barcode + set in Odoo. + """ + value = ai_value.date or ai_value.value + if ai_value.gtin and ai_value.gtin.payload: + value = ai_value.gtin.payload + str(ai_value.gtin.check_digit) + return value + @classmethod def parse(cls, barcode, ai_whitelist=None, safe=True): """Parse given barcode :param barcode: valid GS1 barcode - :param ai_whitelist: ordered list of AI to look for + :param ai_whitelist: ordered list of AI to look for() :param safe: break or not if barcode is invalid :return: a list of `GS1Barcode` instances. @@ -63,7 +77,7 @@ def parse(cls, barcode, ai_whitelist=None, safe=True): if found: # when value is a date the datetime obj is in `date` # TODO: other types have their own special key - value = found.date or found.value + value = cls._get_value(found) info = cls( ai=ai, code=barcode, From bf6d4c5f9f2211afcf8ed78b40c7de3bff7ca0d1 Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Mon, 7 Apr 2025 08:54:11 +0200 Subject: [PATCH 10/13] [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 ef8343c6ec206f5500fbf440e64641be57dc51ed Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Mon, 7 Apr 2025 18:32:39 +0200 Subject: [PATCH 11/13] [IMP] shopfloor_gs1: Return correct value for parsing / typing --- shopfloor_gs1/actions/search.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/shopfloor_gs1/actions/search.py b/shopfloor_gs1/actions/search.py index f8a7c5c9b6a..43f95984ea9 100644 --- a/shopfloor_gs1/actions/search.py +++ b/shopfloor_gs1/actions/search.py @@ -27,7 +27,7 @@ def _gs1_ai_to_search_type(self, ai): """Convert back GS1 AI to search type.""" return MAPPING_AI_TO_TYPE[ai] - def _parse_gs1(self, barcode, types, safe=True): + def _parse_gs1(self, barcode, types, safe=True) -> list[GS1Barcode]: types = types or () ai_whitelist = () # Collect all AIs by converting from search types @@ -37,7 +37,7 @@ def _parse_gs1(self, barcode, types, safe=True): ai_whitelist += ai if types and not ai_whitelist: # A specific type was asked but no AI could be found. - return None, None + return list() parsed = GS1Barcode.parse(barcode, ai_whitelist=ai_whitelist, safe=safe) return parsed @@ -49,7 +49,6 @@ def parse(self, barcode, types): Override this to implement specific parsing """ - # search = self._actions_for("search") parsed = self._parse_gs1(barcode, types) if parsed: result = [] From 8a5f8415ae30f1263fa48180c3a3889e404b3fc2 Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Mon, 7 Apr 2025 18:34:00 +0200 Subject: [PATCH 12/13] [FIX] shopfloor_gs1: Don't mix GTIN-13 and GTIN-14 As products barcode in Odoo are stored in GTIN-13 (EAN-13), and as GS1 contains the GTIN-14 representation, don't mix them in tests --- shopfloor_gs1/tests/common.py | 13 +++++++------ shopfloor_gs1/tests/test_utils.py | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/shopfloor_gs1/tests/common.py b/shopfloor_gs1/tests/common.py index 34617e081a3..d9575979fed 100644 --- a/shopfloor_gs1/tests/common.py +++ b/shopfloor_gs1/tests/common.py @@ -1,12 +1,13 @@ -PROD_BARCODE = "09506000117843" +PROD_BARCODE = "9506000117843" +PROD_GTIN14 = "09506000117843" MANUF_CODE = "K000075" DATE = "141231" LOT1 = "1234AB" LOT2 = "1234AC" -GS1_GTIN_BARCODE_1_HRI = f"(01){PROD_BARCODE}(11){DATE}(10){LOT1}" -GS1_GTIN_BARCODE_2_HRI = f"(01){PROD_BARCODE}(11){DATE}(10){LOT2}" +GS1_GTIN_BARCODE_1_HRI = f"(01){PROD_GTIN14}(11){DATE}(10){LOT1}" +GS1_GTIN_BARCODE_2_HRI = f"(01){PROD_GTIN14}(11){DATE}(10){LOT2}" GS1_MANUF_BARCODE_HRI = f"(240){MANUF_CODE}(11){DATE}(10){LOT1}" -GS1_GTIN_BARCODE_1 = f"01{PROD_BARCODE}11{DATE}10{LOT1}" -GS1_GTIN_BARCODE_2 = f"01{PROD_BARCODE}11{DATE}10{LOT2}" -GS1_MANUF_BARCODE = f"01{PROD_BARCODE}11{DATE}10{LOT1}\x1d240{MANUF_CODE}" +GS1_GTIN_BARCODE_1 = f"01{PROD_GTIN14}11{DATE}10{LOT1}" +GS1_GTIN_BARCODE_2 = f"01{PROD_GTIN14}11{DATE}10{LOT2}" +GS1_MANUF_BARCODE = f"01{PROD_GTIN14}11{DATE}10{LOT1}\x1d240{MANUF_CODE}" diff --git a/shopfloor_gs1/tests/test_utils.py b/shopfloor_gs1/tests/test_utils.py index d5c85e763ba..fdf7e3a3147 100644 --- a/shopfloor_gs1/tests/test_utils.py +++ b/shopfloor_gs1/tests/test_utils.py @@ -15,7 +15,7 @@ def test_parse1(self): self.assertEqual(len(res), 3, res) item = [x for x in res if x.ai == "01"][0] self.assertEqual(item.code, code) - self.assertEqual(item.value, "09506000117843") + self.assertEqual(item.value, "9506000117843") self.assertEqual(item.raw_value, "09506000117843") item = [x for x in res if x.ai == "11"][0] self.assertEqual(item.code, code) @@ -32,7 +32,7 @@ def test_parse2(self): self.assertEqual(len(res), 1, res) item = [x for x in res if x.ai == "01"][0] self.assertEqual(item.code, code) - self.assertEqual(item.value, "09506000117843") + self.assertEqual(item.value, "9506000117843") self.assertEqual(item.raw_value, "09506000117843") def test_parse3(self): From 6da2cd600dc162a42c1ce180574bc18b25c388ad Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Thu, 3 Jul 2025 10:28:11 +0200 Subject: [PATCH 13/13] [IMP] shopfloor_gs1: Also return the other parsing results --- shopfloor_gs1/actions/search.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/shopfloor_gs1/actions/search.py b/shopfloor_gs1/actions/search.py index 43f95984ea9..87b2798d58c 100644 --- a/shopfloor_gs1/actions/search.py +++ b/shopfloor_gs1/actions/search.py @@ -49,9 +49,10 @@ def parse(self, barcode, types): Override this to implement specific parsing """ + # Retrieve in any case the 'unknown' parsing with raw barcode + result = super().parse(barcode, types) parsed = self._parse_gs1(barcode, types) if parsed: - result = [] for barcode_type in self.search_action._barcode_type_handler.keys(): for parsed_item in parsed: if parsed_item.ai in MAPPING_TYPE_TO_AI.get(barcode_type, tuple()): @@ -62,6 +63,4 @@ def parse(self, barcode, types): raw=parsed_item.raw_value, ) ) - if result: - return result - return super().parse(barcode, types) + return result