From 639e219f71c1fa2751c6926139f4ed1ebbc3080e Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 7 Dec 2022 16:36:39 +0100 Subject: [PATCH 1/6] Add shopfloor_gs1 --- requirements.txt | 1 + setup/shopfloor_gs1/odoo/addons/shopfloor_gs1 | 1 + setup/shopfloor_gs1/setup.py | 6 ++ 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 +++++++++++++++++++ shopfloor_rest_log/__manifest__.py | 2 +- 16 files changed, 273 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/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/requirements.txt b/requirements.txt index 9cd1629223b..e3d2e53b723 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ # generated from manifests external_dependencies +biip 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 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 diff --git a/shopfloor_rest_log/__manifest__.py b/shopfloor_rest_log/__manifest__.py index a10a367823d..6adfbb04bb7 100644 --- a/shopfloor_rest_log/__manifest__.py +++ b/shopfloor_rest_log/__manifest__.py @@ -9,7 +9,7 @@ "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", - "author": "ACSONE, Odoo Community Association (OCA)", + "author": "ACSONE, Camptocamp, Odoo Community Association (OCA)", "maintainers": ["simahawk"], "license": "LGPL-3", "depends": ["rest_log", "shopfloor_base"], From 615f6ccdd3e39942c372b923acc409affb9400aa Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 11 Jan 2023 14:14:48 +0100 Subject: [PATCH 2/6] shopfloor_gs1: pin biip==2.3.0 --- requirements.txt | 2 +- shopfloor_gs1/__manifest__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index e3d2e53b723..ad102860595 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ # generated from manifests external_dependencies -biip +biip==2.3.0 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 873915a9e35b20df573184d22cc2c3ef7766f351 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 2 Feb 2023 18:12:27 +0100 Subject: [PATCH 3/6] 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 ec10f425044465296e5050df60bfe75c8fc7dfb5 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 23 Feb 2023 13:56:09 +0100 Subject: [PATCH 4/6] 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 faad1f1bd2d0c51ed0e7acf6b34926baefe30d43 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 23 Feb 2023 14:01:16 +0100 Subject: [PATCH 5/6] 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 7b5a0a30c6018297f972e9cfb9270fe209f2085a 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 6/6] 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": [], }