diff --git a/requirements.txt b/requirements.txt index 180fc49789..7a08bafecc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ # generated from manifests external_dependencies +biip 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 new file mode 100644 index 0000000000..bd5fa28e30 --- /dev/null +++ b/shopfloor_gs1/README.rst @@ -0,0 +1,99 @@ +============= +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/ + +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** + +.. contents:: + :local: + +Usage +===== + +- You can use the `Scan` action in Shopfloor with a GS1 barcode. The + +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 +* Denis Roussel + +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/__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..4656e22d88 --- /dev/null +++ b/shopfloor_gs1/__manifest__.py @@ -0,0 +1,24 @@ +# 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": "16.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": [ + # >= 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" + ] + }, +} 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..153634ae7f --- /dev/null +++ b/shopfloor_gs1/actions/search.py @@ -0,0 +1,102 @@ +# 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. + + 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 + _, 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 + ) -> tuple[list[GS1Barcode], Model] | None: + types = types or () + 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 + 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: + record = self.generic_find( + item.value, + types=(self._gs1_ai_to_search_type(item.ai),), + handler_kw=handler_kw, + ) + if record: + return parsed, record diff --git a/shopfloor_gs1/config.py b/shopfloor_gs1/config.py new file mode 100644 index 0000000000..4f3f924d1d --- /dev/null +++ b/shopfloor_gs1/config.py @@ -0,0 +1,28 @@ +# 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",), + "expiration_date": ("17",), + "serial": ("21",), + "manuf_product_code": ("240",), + "location": ("254",), +} +MAPPING_AI_TO_TYPE = { + "01": "product", + "10": "lot", + "11": "production_date", + "17": "expiration_date", + "21": "serial", + "240": "product", + "254": "location", +} diff --git a/shopfloor_gs1/readme/CONTRIBUTORS.rst b/shopfloor_gs1/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..2dfc355afe --- /dev/null +++ b/shopfloor_gs1/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* Simone Orsi +* Sébastien Alix +* Denis Roussel diff --git a/shopfloor_gs1/readme/DESCRIPTION.rst b/shopfloor_gs1/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..849abae8cb --- /dev/null +++ b/shopfloor_gs1/readme/DESCRIPTION.rst @@ -0,0 +1,6 @@ +Add GS1 barcode support to Shopfloor. + +Based on https://biip.readthedocs.io/ + +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 new file mode 100644 index 0000000000..6da03b4481 --- /dev/null +++ b/shopfloor_gs1/readme/USAGE.rst @@ -0,0 +1 @@ +- 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 new file mode 100644 index 0000000000..7b35c4df3c --- /dev/null +++ b/shopfloor_gs1/static/description/index.html @@ -0,0 +1,437 @@ + + + + + +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/

+

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

+ +
+

Usage

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

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

+ +
+
+

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.

+
+
+
+ + diff --git a/shopfloor_gs1/tests/__init__.py b/shopfloor_gs1/tests/__init__.py new file mode 100644 index 0000000000..cd472a1a25 --- /dev/null +++ b/shopfloor_gs1/tests/__init__.py @@ -0,0 +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 new file mode 100644 index 0000000000..34617e081a --- /dev/null +++ b/shopfloor_gs1/tests/common.py @@ -0,0 +1,12 @@ +PROD_BARCODE = "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_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 new file mode 100644 index 0000000000..be659c14bd --- /dev/null +++ b/shopfloor_gs1/tests/test_action_search.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, + GS1_MANUF_BARCODE, + LOT1, + MANUF_CODE, + PROD_BARCODE, +) + + +class TestFind(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 + "\x1d254" + 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.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) 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 new file mode 100644 index 0000000000..17f88997bb --- /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 + "\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) + + 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.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) diff --git a/shopfloor_gs1/tests/test_utils.py b/shopfloor_gs1/tests/test_utils.py new file mode 100644 index 0000000000..d5c85e763b --- /dev/null +++ b/shopfloor_gs1/tests/test_utils.py @@ -0,0 +1,74 @@ +# 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.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.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.ai == "10"][0] + 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.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")) + 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") + + 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 new file mode 100644 index 0000000000..caca5ac8cd --- /dev/null +++ b/shopfloor_gs1/utils.py @@ -0,0 +1,74 @@ +# 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 + +from .config import MAPPING_AI_TO_TYPE + +DEFAULT_AI_WHITELIST = tuple(MAPPING_AI_TO_TYPE.keys()) + + +class GS1Barcode: + """GS1 barcode parser and wrapper.""" + + __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}>" + + def __bool__(self): + return bool(self.ai) + + 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, 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: a list of `GS1Barcode` instances. + """ + res = [] + try: + # TODO: we might not get an HRI... + parsed = GS1Message.parse_hri(barcode) + except ParseError: + 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 + ai_whitelist = ai_whitelist or DEFAULT_AI_WHITELIST + for ai in ai_whitelist: + 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, + code=barcode, + raw_value=found.value, + value=value, + ) + res.append(info) + return res