diff --git a/requirements.txt b/requirements.txt index 9cd1629223..ad10286059 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ # generated from manifests external_dependencies +biip==2.3.0 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..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..86a3cdbaea --- /dev/null +++ b/shopfloor_gs1/__manifest__.py @@ -0,0 +1,25 @@ +# 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": [ + # >= 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": [], +} 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..c390cae37d --- /dev/null +++ b/shopfloor_gs1/actions/search.py @@ -0,0 +1,52 @@ +# 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 ..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) + + def _find_gs1(self, barcode, types=None, handler_kw=None, safe=True): + 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 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/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..412f8593b8 --- /dev/null +++ b/shopfloor_gs1/tests/__init__.py @@ -0,0 +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 new file mode 100644 index 0000000000..fd5ec6e942 --- /dev/null +++ b/shopfloor_gs1/tests/test_action_search.py @@ -0,0 +1,73 @@ +# 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): + 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_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) diff --git a/shopfloor_gs1/tests/test_utils.py b/shopfloor_gs1/tests/test_utils.py new file mode 100644 index 0000000000..7ba0935f32 --- /dev/null +++ b/shopfloor_gs1/tests/test_utils.py @@ -0,0 +1,59 @@ +# 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") diff --git a/shopfloor_gs1/utils.py b/shopfloor_gs1/utils.py new file mode 100644 index 0000000000..04e5280089 --- /dev/null +++ b/shopfloor_gs1/utils.py @@ -0,0 +1,71 @@ +# 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 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, 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 + # 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 diff --git a/shopfloor_rest_log/__manifest__.py b/shopfloor_rest_log/__manifest__.py index a10a367823..6adfbb04bb 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"],