From 52d06918c87d74de3b66b4d6cb93c57fe4102113 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 7 Dec 2022 16:36:39 +0100 Subject: [PATCH 1/9] 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 0000000000..7f0885e84e --- /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 0000000000..f5fe63aaf7 --- /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 0000000000..fcbe9ba625 --- /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 0000000000..74d7cf6a34 --- /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 0000000000..c6f832feee --- /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 0000000000..8f258ba525 --- /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 0000000000..b37a8ddc1a --- /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 0000000000..1333ed77b7 --- /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 0000000000..cdb78752c9 --- /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 0000000000..392168f206 --- /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 0000000000..a90d75b6d9 --- /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 0000000000..10c15723a6 --- /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 0ecc861459e14b4f7ffb1069ca92f24a31f8806e Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Wed, 11 Jan 2023 14:14:48 +0100 Subject: [PATCH 2/9] 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 fcbe9ba625..58abc011db 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 d4e46f21ea2a1b9d7a5284c870c1ae4e98f72d0a Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 2 Feb 2023 18:12:27 +0100 Subject: [PATCH 3/9] 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 c6f832feee..76d51d7ef5 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 0000000000..2e03efa7c0 --- /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 cdb78752c9..266b0e51d3 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 0000000000..b99a25c2c9 --- /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 392168f206..0000000000 --- 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 a90d75b6d9..7ba0935f32 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 10c15723a6..04e5280089 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 ab69059efc90c90718d0272dd801836f3cc74fca Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 23 Feb 2023 13:56:09 +0100 Subject: [PATCH 4/9] 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 266b0e51d3..412f8593b8 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 0000000000..ae5367c87a --- /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 b99a25c2c9..fd5ec6e942 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 0000000000..7b9660c0cc --- /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 5e29ca87b4aede37be132bd5341bf57f82f66479 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Thu, 23 Feb 2023 14:01:16 +0100 Subject: [PATCH 5/9] 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 76d51d7ef5..c390cae37d 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 3e419ac37eaf107010d6036d86864cbfd73fdff7 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/9] 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 58abc011db..86a3cdbaea 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 da3bc34d174469ebb6ed5a3d194b7defd4292c07 Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Mon, 16 Dec 2024 17:07:58 +0100 Subject: [PATCH 7/9] [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 180fc49789..ec5d966bb6 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 0000000000..59b110fbbb --- /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 0000000000..28c57bb640 --- /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 7f0885e84e..097cd077e2 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 0000000000..2fcd78af4b --- /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 949d8afd367f0b8c3f8400be33223ac520c9be32 Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Mon, 6 Jan 2025 09:43:33 +0100 Subject: [PATCH 8/9] [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 ec5d966bb6..7a08bafecc 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 097cd077e2..bd5fa28e30 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 86a3cdbaea..4656e22d88 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 8f258ba525..2dfc355afe 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 b37a8ddc1a..849abae8cb 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 1333ed77b7..6da03b4481 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 2fcd78af4b..7b35c4df3c 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 412f8593b8..cd472a1a25 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 ae5367c87a..34617e081a 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 fd5ec6e942..be659c14bd 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 0000000000..e9fa3a884c --- /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 7b9660c0cc..17f88997bb 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 7ba0935f32..d5c85e763b 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 04e5280089..caca5ac8cd 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 39e5ade97c765f16e3848a7feb819fbbef736ef1 Mon Sep 17 00:00:00 2001 From: Denis Roussel Date: Thu, 16 Jan 2025 11:50:14 +0100 Subject: [PATCH 9/9] [WIP] shopfloor_gs1 --- shopfloor_gs1/actions/search.py | 60 ++++++++++++++++++++++++++++++--- shopfloor_gs1/config.py | 2 ++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/shopfloor_gs1/actions/search.py b/shopfloor_gs1/actions/search.py index c390cae37d..153634ae7f 100644 --- a/shopfloor_gs1/actions/search.py +++ b/shopfloor_gs1/actions/search.py @@ -1,14 +1,62 @@ # Copyright 2022 Camptocamp SA (http://www.camptocamp.com) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.models import Model + 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 BarcodeParser(Component): + """ + Some barcodes can have complex data structure + """ + + _inherit = "shopfloor.barcode.parser" + + # def __init__(self, search_action: SearchAction): + # # Get search action keys + # self.search_action = search_action + + # @property + # def _authorized_barcode_types(self): + # return self.search_action._barcode_type_handler.keys() + + def parse(self, barcode): + """ + 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, record = search._find_gs1(barcode) + if parsed: + result = [] + for barcode_type in search._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) + + class SearchAction(Component): _inherit = "shopfloor.search.action" + def _get_barcode_parser(self): + return self.env[""] + def _search_type_to_gs1_ai(self, _type): """Convert search type to AIs. @@ -24,12 +72,14 @@ def _gs1_ai_to_search_type(self, 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 + _, record = self._find_gs1(barcode, types=types) + if record: + return record return super().find(barcode, types=types, handler_kw=handler_kw) - def _find_gs1(self, barcode, types=None, handler_kw=None, safe=True): + def _find_gs1( + self, barcode, types=None, handler_kw=None, safe=True + ) -> tuple[list[GS1Barcode], Model] | None: types = types or () ai_whitelist = () # Collect all AIs by converting from search types @@ -49,4 +99,4 @@ def _find_gs1(self, barcode, types=None, handler_kw=None, safe=True): handler_kw=handler_kw, ) if record: - return record + return parsed, record diff --git a/shopfloor_gs1/config.py b/shopfloor_gs1/config.py index 2e03efa7c0..4f3f924d1d 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",