From df0da18d2f5219e0c2ead9eb68af9bf0685935b7 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 15 Nov 2023 23:35:31 +1100 Subject: [PATCH] Refactoring supplier barcode support (#5922) * Refactoring supplier barcode support - Add a set of standard field name strings - Map from custom fields to standard fields - Helper functions for returning common field data - Updated unit tests * Update unit tests * Fix unit test * Add more unit tests' * Improve error messages --- InvenTree/plugin/base/barcodes/api.py | 4 +- InvenTree/plugin/base/barcodes/mixins.py | 291 ++++++++++++------ InvenTree/plugin/builtin/suppliers/digikey.py | 23 +- InvenTree/plugin/builtin/suppliers/lcsc.py | 44 ++- InvenTree/plugin/builtin/suppliers/mouser.py | 16 +- .../suppliers/test_supplier_barcodes.py | 103 +++++-- InvenTree/plugin/builtin/suppliers/tme.py | 68 ++-- 7 files changed, 343 insertions(+), 206 deletions(-) diff --git a/InvenTree/plugin/base/barcodes/api.py b/InvenTree/plugin/base/barcodes/api.py index 8a450491d7..c802150ff8 100644 --- a/InvenTree/plugin/base/barcodes/api.py +++ b/InvenTree/plugin/base/barcodes/api.py @@ -340,7 +340,7 @@ class BarcodePOReceive(APIView): # A plugin has not been found! if plugin is None: - response["error"] = _("Invalid supplier barcode") + response["error"] = _("No match for supplier barcode") raise ValidationError(response) elif "error" in response: raise ValidationError(response) @@ -352,7 +352,7 @@ barcode_api_urls = [ # Link a third-party barcode to an item (e.g. Part / StockItem / etc) path('link/', BarcodeAssign.as_view(), name='api-barcode-link'), - # Unlink a third-pary barcode from an item + # Unlink a third-party barcode from an item path('unlink/', BarcodeUnassign.as_view(), name='api-barcode-unlink'), # Receive a purchase order item by scanning its barcode diff --git a/InvenTree/plugin/base/barcodes/mixins.py b/InvenTree/plugin/base/barcodes/mixins.py index e096dd1bf4..a3511b4df1 100644 --- a/InvenTree/plugin/base/barcodes/mixins.py +++ b/InvenTree/plugin/base/barcodes/mixins.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -from dataclasses import dataclass from decimal import Decimal, InvalidOperation from django.contrib.auth.models import User @@ -55,52 +54,99 @@ class BarcodeMixin: return None -@dataclass -class SupplierBarcodeData: - """Data parsed from a supplier barcode.""" - SKU: str = None - MPN: str = None - quantity: Decimal | str = None - order_number: str = None - - class SupplierBarcodeMixin(BarcodeMixin): """Mixin that provides default implementations for scan functions for supplier barcodes. Custom supplier barcode plugins should use this mixin and implement the - parse_supplier_barcode_data function. + extract_barcode_fields function. """ + # Set of standard field names which can be extracted from the barcode + CUSTOMER_ORDER_NUMBER = "customer_order_number" + SUPPLIER_ORDER_NUMBER = "supplier_order_number" + PACKING_LIST_NUMBER = "packing_list_number" + SHIP_DATE = "ship_date" + CUSTOMER_PART_NUMBER = "customer_part_number" + SUPPLIER_PART_NUMBER = "supplier_part_number" + PURCHASE_ORDER_LINE = "purchase_order_line" + QUANTITY = "quantity" + DATE_CODE = "date_code" + LOT_CODE = "lot_code" + COUNTRY_OF_ORIGIN = "country_of_origin" + MANUFACTURER = "manufacturer" + MANUFACTURER_PART_NUMBER = "manufacturer_part_number" + def __init__(self): """Register mixin.""" super().__init__() self.add_mixin('supplier-barcode', True, __class__) - def parse_supplier_barcode_data(self, barcode_data) -> SupplierBarcodeData | None: - """Get supplier_part and other barcode_fields from barcode data. + def get_field_value(self, key, backup_value=None): + """Return the value of a barcode field.""" + fields = getattr(self, "barcode_fields", None) or {} + + return fields.get(key, backup_value) + + @property + def quantity(self): + """Return the quantity from the barcode fields.""" + return self.get_field_value(self.QUANTITY) + + @property + def supplier_part_number(self): + """Return the supplier part number from the barcode fields.""" + return self.get_field_value(self.SUPPLIER_PART_NUMBER) + + @property + def manufacturer_part_number(self): + """Return the manufacturer part number from the barcode fields.""" + return self.get_field_value(self.MANUFACTURER_PART_NUMBER) + + @property + def customer_order_number(self): + """Return the customer order number from the barcode fields.""" + return self.get_field_value(self.CUSTOMER_ORDER_NUMBER) + + @property + def supplier_order_number(self): + """Return the supplier order number from the barcode fields.""" + return self.get_field_value(self.SUPPLIER_ORDER_NUMBER) + + def extract_barcode_fields(self, barcode_data) -> dict[str, str]: + """Method to extract barcode fields from barcode data. + + This method should return a dict object where the keys are the field names, + as per the "standard field names" (defined in the SuppliedBarcodeMixin class). + + This method *must* be implemented by each plugin Returns: - None if the barcode_data is not from a valid barcode of the supplier. + A dict object containing the barcode fields. - A SupplierBarcodeData object containing the SKU, MPN, quantity and order number - if available. """ - - return None + raise NotImplementedError("extract_barcode_fields must be implemented by each plugin") def scan(self, barcode_data): """Try to match a supplier barcode to a supplier part.""" - if not (parsed := self.parse_supplier_barcode_data(barcode_data)): - return None - if parsed.SKU is None and parsed.MPN is None: + barcode_data = str(barcode_data).strip() + + self.barcode_fields = self.extract_barcode_fields(barcode_data) + + if self.supplier_part_number is None and self.manufacturer_part_number is None: return None - supplier_parts = self.get_supplier_parts(parsed.SKU, self.get_supplier(), parsed.MPN) + supplier_parts = self.get_supplier_parts( + sku=self.supplier_part_number, + mpn=self.manufacturer_part_number, + supplier=self.get_supplier(), + ) + if len(supplier_parts) > 1: return {"error": _("Found multiple matching supplier parts for barcode")} elif not supplier_parts: return None + supplier_part = supplier_parts[0] data = { @@ -109,28 +155,61 @@ class SupplierBarcodeMixin(BarcodeMixin): "web_url": supplier_part.get_absolute_url(), } - return {SupplierPart.barcode_model_type(): data} + return { + SupplierPart.barcode_model_type(): data + } def scan_receive_item(self, barcode_data, user, purchase_order=None, location=None): """Try to scan a supplier barcode to receive a purchase order item.""" - if not (parsed := self.parse_supplier_barcode_data(barcode_data)): - return None - if parsed.SKU is None and parsed.MPN is None: + barcode_data = str(barcode_data).strip() + + self.barcode_fields = self.extract_barcode_fields(barcode_data) + + if self.supplier_part_number is None and self.manufacturer_part_number is None: return None - supplier_parts = self.get_supplier_parts(parsed.SKU, self.get_supplier(), parsed.MPN) + supplier = self.get_supplier() + + supplier_parts = self.get_supplier_parts( + sku=self.supplier_part_number, + mpn=self.manufacturer_part_number, + supplier=supplier, + ) + if len(supplier_parts) > 1: return {"error": _("Found multiple matching supplier parts for barcode")} elif not supplier_parts: return None + supplier_part = supplier_parts[0] + # If a purchase order is not provided, extract it from the provided data + if not purchase_order: + matching_orders = self.get_purchase_orders( + self.customer_order_number, + self.supplier_order_number, + supplier=supplier, + ) + + order = self.customer_order_number or self.supplier_order_number + + if len(matching_orders) > 1: + return {"error": _(f"Found multiple purchase orders matching '{order}'")} + + if len(matching_orders) == 0: + return {"error": _(f"No matching purchase order for '{order}'")} + + purchase_order = matching_orders.first() + + if supplier and purchase_order: + if purchase_order.supplier != supplier: + return {"error": _("Purchase order does not match supplier")} + return self.receive_purchase_order_item( supplier_part, user, - quantity=parsed.quantity, - order_number=parsed.order_number, + quantity=self.quantity, purchase_order=purchase_order, location=location, barcode=barcode_data, @@ -159,52 +238,124 @@ class SupplierBarcodeMixin(BarcodeMixin): return None suppliers = Company.objects.filter(name__icontains=supplier_name, is_supplier=True) + if len(suppliers) != 1: return None + self.set_setting("SUPPLIER_ID", suppliers.first().pk) return suppliers.first() - @staticmethod - def parse_ecia_barcode2d(barcode_data: str | list[str]) -> dict[str, str]: - """Parse a standard ECIA 2D barcode, according to https://www.ecianow.org/assets/docs/ECIA_Specifications.pdf""" + @classmethod + def ecia_field_map(cls): + """Return a dict mapping ECIA field names to internal field names - if not isinstance(barcode_data, str): - data_split = barcode_data - elif not (data_split := SupplierBarcodeMixin.parse_isoiec_15434_barcode2d(barcode_data)): - return None + Ref: https://www.ecianow.org/assets/docs/ECIA_Specifications.pdf + + Note that a particular plugin may need to reimplement this method, + if it does not use the standard field names. + """ + return { + "K": cls.CUSTOMER_ORDER_NUMBER, + "1K": cls.SUPPLIER_ORDER_NUMBER, + "11K": cls.PACKING_LIST_NUMBER, + "6D": cls.SHIP_DATE, + "9D": cls.DATE_CODE, + "10D": cls.DATE_CODE, + "4K": cls.PURCHASE_ORDER_LINE, + "14K": cls.PURCHASE_ORDER_LINE, + "P": cls.SUPPLIER_PART_NUMBER, + "1P": cls.MANUFACTURER_PART_NUMBER, + "30P": cls.SUPPLIER_PART_NUMBER, + "1T": cls.LOT_CODE, + "4L": cls.COUNTRY_OF_ORIGIN, + "1V": cls.MANUFACTURER, + "Q": cls.QUANTITY, + } + + @classmethod + def parse_ecia_barcode2d(cls, barcode_data: str) -> dict[str, str]: + """Parse a standard ECIA 2D barcode + + Ref: https://www.ecianow.org/assets/docs/ECIA_Specifications.pdf + + Arguments: + barcode_data: The raw barcode data + + Returns: + A dict containing the parsed barcode fields + """ + + # Split data into separate fields + fields = cls.parse_isoiec_15434_barcode2d(barcode_data) barcode_fields = {} - for entry in data_split: - for identifier, field_name in ECIA_DATA_IDENTIFIER_MAP.items(): - if entry.startswith(identifier): - barcode_fields[field_name] = entry[len(identifier):] + + if not fields: + return barcode_fields + + for field in fields: + for identifier, field_name in cls.ecia_field_map().items(): + if field.startswith(identifier): + barcode_fields[field_name] = field[len(identifier):] break return barcode_fields + @staticmethod + def split_fields(barcode_data: str, delimiter: str = ',', header: str = '', trailer: str = '') -> list[str]: + """Generic method for splitting barcode data into separate fields""" + + if header and barcode_data.startswith(header): + barcode_data = barcode_data[len(header):] + + if trailer and barcode_data.endswith(trailer): + barcode_data = barcode_data[:-len(trailer)] + + return barcode_data.split(delimiter) + @staticmethod def parse_isoiec_15434_barcode2d(barcode_data: str) -> list[str]: - """Parse a ISO/IEC 15434 bardode, returning the split data section.""" + """Parse a ISO/IEC 15434 barcode, returning the split data section.""" + + OLD_MOUSER_HEADER = ">[)>06\x1D" HEADER = "[)>\x1E06\x1D" TRAILER = "\x1E\x04" + DELIMITER = "\x1D" - # some old mouser barcodes start with this messed up header - OLD_MOUSER_HEADER = ">[)>06\x1D" + # Some old mouser barcodes start with this messed up header if barcode_data.startswith(OLD_MOUSER_HEADER): barcode_data = barcode_data.replace(OLD_MOUSER_HEADER, HEADER, 1) - # most barcodes don't include the trailer, because "why would you stick to - # the standard, right?" so we only check for the header here + # Check that the barcode starts with the necessary header if not barcode_data.startswith(HEADER): return - actual_data = barcode_data.split(HEADER, 1)[1].rsplit(TRAILER, 1)[0] - - return actual_data.split("\x1D") + return SupplierBarcodeMixin.split_fields( + barcode_data, + delimiter=DELIMITER, + header=HEADER, + trailer=TRAILER, + ) @staticmethod - def get_supplier_parts(sku: str, supplier: Company = None, mpn: str = None): + def get_purchase_orders(customer_order_number, supplier_order_number, supplier: Company = None): + """Attempt to find a purchase order from the extracted customer and supplier order numbers""" + + orders = PurchaseOrder.objects.filter(status=PurchaseOrderStatus.PLACED.value) + + if supplier: + orders = orders.filter(supplier=supplier) + + if customer_order_number: + orders = orders.filter(reference__iexact=customer_order_number) + elif supplier_order_number: + orders = orders.filter(supplier_reference__iexact=supplier_order_number) + + return orders + + @staticmethod + def get_supplier_parts(sku: str = None, supplier: Company = None, mpn: str = None): """Get a supplier part from SKU or by supplier and MPN.""" if not (sku or supplier or mpn): return SupplierPart.objects.none() @@ -241,7 +392,6 @@ class SupplierBarcodeMixin(BarcodeMixin): supplier_part: SupplierPart, user: User, quantity: Decimal | str = None, - order_number: str = None, purchase_order: PurchaseOrder = None, location: StockLocation = None, barcode: str = None, @@ -255,27 +405,6 @@ class SupplierBarcodeMixin(BarcodeMixin): - on failure: an "error" message """ - if not purchase_order: - # try to find a purchase order with either reference or name matching - # the provided order_number - if not order_number: - return {"error": _("Supplier barcode doesn't contain order number")} - - purchase_orders = ( - PurchaseOrder.objects.filter( - supplier_reference__iexact=order_number, - status=PurchaseOrderStatus.PLACED.value, - ) | PurchaseOrder.objects.filter( - reference__iexact=order_number, - status=PurchaseOrderStatus.PLACED.value, - ) - ) - - if len(purchase_orders) > 1: - return {"error": _(f"Found multiple placed purchase orders for '{order_number}'")} - elif not (purchase_order := purchase_orders.first()): - return {"error": _(f"Failed to find placed purchase order for '{order_number}'")} - if quantity: try: quantity = Decimal(quantity) @@ -350,23 +479,3 @@ class SupplierBarcodeMixin(BarcodeMixin): response["success"] = _("Received purchase order line item") return response - - -# Map ECIA Data Identifier to human readable identifier -# The following identifiers haven't been implemented: 3S, 4S, 5S, S -ECIA_DATA_IDENTIFIER_MAP = { - "K": "purchase_order_number", # noqa: E241 - "1K": "purchase_order_number", # noqa: E241 DigiKey uses 1K instead of K - "11K": "packing_list_number", # noqa: E241 - "6D": "ship_date", # noqa: E241 - "P": "supplier_part_number", # noqa: E241 "Customer Part Number" - "1P": "manufacturer_part_number", # noqa: E241 "Supplier Part Number" - "4K": "purchase_order_line", # noqa: E241 - "14K": "purchase_order_line", # noqa: E241 Mouser uses 14K instead of 4K - "Q": "quantity", # noqa: E241 - "9D": "date_yyww", # noqa: E241 - "10D": "date_yyww", # noqa: E241 - "1T": "lot_code", # noqa: E241 - "4L": "country_of_origin", # noqa: E241 - "1V": "manufacturer" # noqa: E241 -} diff --git a/InvenTree/plugin/builtin/suppliers/digikey.py b/InvenTree/plugin/builtin/suppliers/digikey.py index 67db4db723..bf8da46dd1 100644 --- a/InvenTree/plugin/builtin/suppliers/digikey.py +++ b/InvenTree/plugin/builtin/suppliers/digikey.py @@ -6,7 +6,6 @@ This plugin can currently only match DigiKey barcodes to supplier parts. from django.utils.translation import gettext_lazy as _ from plugin import InvenTreePlugin -from plugin.base.barcodes.mixins import SupplierBarcodeData from plugin.mixins import SettingsMixin, SupplierBarcodeMixin @@ -20,6 +19,7 @@ class DigiKeyPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin): AUTHOR = _("InvenTree contributors") DEFAULT_SUPPLIER_NAME = "DigiKey" + SETTINGS = { "SUPPLIER_ID": { "name": _("Supplier"), @@ -28,22 +28,7 @@ class DigiKeyPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin): } } - def parse_supplier_barcode_data(self, barcode_data): - """Get supplier_part and barcode_fields from DigiKey DataMatrix-Code.""" + def extract_barcode_fields(self, barcode_data) -> dict[str, str]: + """Extract barcode fields from a DigiKey plugin""" - if not isinstance(barcode_data, str): - return None - - if not (barcode_fields := self.parse_ecia_barcode2d(barcode_data)): - return None - - # digikey barcodes should always contain a SKU - if "supplier_part_number" not in barcode_fields: - return None - - return SupplierBarcodeData( - SKU=barcode_fields.get("supplier_part_number"), - MPN=barcode_fields.get("manufacturer_part_number"), - quantity=barcode_fields.get("quantity"), - order_number=barcode_fields.get("purchase_order_number"), - ) + return self.parse_ecia_barcode2d(barcode_data) diff --git a/InvenTree/plugin/builtin/suppliers/lcsc.py b/InvenTree/plugin/builtin/suppliers/lcsc.py index 2b9e245db2..6674b39924 100644 --- a/InvenTree/plugin/builtin/suppliers/lcsc.py +++ b/InvenTree/plugin/builtin/suppliers/lcsc.py @@ -8,7 +8,6 @@ import re from django.utils.translation import gettext_lazy as _ from plugin import InvenTreePlugin -from plugin.base.barcodes.mixins import SupplierBarcodeData from plugin.mixins import SettingsMixin, SupplierBarcodeMixin @@ -30,23 +29,40 @@ class LCSCPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin): } } - def parse_supplier_barcode_data(self, barcode_data): - """Get supplier_part and barcode_fields from LCSC QR-Code.""" + LCSC_BARCODE_REGEX = re.compile(r"^{((?:[^:,]+:[^:,]*,)*(?:[^:,]+:[^:,]*))}$") - if not isinstance(barcode_data, str): - return None + # Custom field mapping for LCSC barcodes + LCSC_FIELDS = { + "pm": SupplierBarcodeMixin.MANUFACTURER_PART_NUMBER, + "pc": SupplierBarcodeMixin.SUPPLIER_PART_NUMBER, + "qty": SupplierBarcodeMixin.QUANTITY, + "on": SupplierBarcodeMixin.CUSTOMER_ORDER_NUMBER, + } - if not (match := LCSC_BARCODE_REGEX.fullmatch(barcode_data)): - return None + def extract_barcode_fields(self, barcode_data: str) -> dict[str, str]: + """Get supplier_part and barcode_fields from LCSC QR-Code. - barcode_fields = dict(pair.split(":") for pair in match.group(1).split(",")) + Example LCSC QR-Code: {pbn:PICK2009291337,on:SO2009291337,pc:C312270} + """ - return SupplierBarcodeData( - SKU=barcode_fields.get("pc"), - MPN=barcode_fields.get("pm"), - quantity=barcode_fields.get("qty"), - order_number=barcode_fields.get("on"), + if not self.LCSC_BARCODE_REGEX.fullmatch(barcode_data): + return {} + + # Extract fields + fields = SupplierBarcodeMixin.split_fields( + barcode_data, + delimiter=',', + header='{', + trailer='}', ) + fields = dict(pair.split(":") for pair in fields) -LCSC_BARCODE_REGEX = re.compile(r"^{((?:[^:,]+:[^:,]*,)*(?:[^:,]+:[^:,]*))}$") + barcode_fields = {} + + # Map from LCSC field names to standard field names + for key, field in self.LCSC_FIELDS.items(): + if key in fields: + barcode_fields[field] = fields[key] + + return barcode_fields diff --git a/InvenTree/plugin/builtin/suppliers/mouser.py b/InvenTree/plugin/builtin/suppliers/mouser.py index a2ab9e4f70..6b75f85535 100644 --- a/InvenTree/plugin/builtin/suppliers/mouser.py +++ b/InvenTree/plugin/builtin/suppliers/mouser.py @@ -6,7 +6,6 @@ This plugin currently only match Mouser barcodes to supplier parts. from django.utils.translation import gettext_lazy as _ from plugin import InvenTreePlugin -from plugin.base.barcodes.mixins import SupplierBarcodeData from plugin.mixins import SettingsMixin, SupplierBarcodeMixin @@ -28,18 +27,7 @@ class MouserPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin): } } - def parse_supplier_barcode_data(self, barcode_data): + def extract_barcode_fields(self, barcode_data: str) -> dict[str, str]: """Get supplier_part and barcode_fields from Mouser DataMatrix-Code.""" - if not isinstance(barcode_data, str): - return None - - if not (barcode_fields := self.parse_ecia_barcode2d(barcode_data)): - return None - - return SupplierBarcodeData( - SKU=barcode_fields.get("supplier_part_number"), - MPN=barcode_fields.get("manufacturer_part_number"), - quantity=barcode_fields.get("quantity"), - order_number=barcode_fields.get("purchase_order_number"), - ) + return self.parse_ecia_barcode2d(barcode_data) diff --git a/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py b/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py index fc70500299..014c66ed17 100644 --- a/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py +++ b/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py @@ -12,6 +12,8 @@ from stock.models import StockItem, StockLocation class SupplierBarcodeTests(InvenTreeAPITestCase): """Tests barcode parsing for all suppliers.""" + SCAN_URL = reverse("api-barcode-scan") + @classmethod def setUpTestData(cls): """Create supplier parts for barcodes.""" @@ -41,76 +43,90 @@ class SupplierBarcodeTests(InvenTreeAPITestCase): SupplierPart.objects.bulk_create(supplier_parts) def test_digikey_barcode(self): - """Test digikey barcode.""" + """Test digikey barcode""" - url = reverse("api-barcode-scan") - result = self.post(url, data={"barcode": DIGIKEY_BARCODE}) + result = self.post(self.SCAN_URL, data={"barcode": DIGIKEY_BARCODE}, expected_code=200) + self.assertEqual(result.data['plugin'], 'DigiKeyPlugin') supplier_part_data = result.data.get("supplierpart") - assert "pk" in supplier_part_data + self.assertIn('pk', supplier_part_data) + supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"]) - assert supplier_part.SKU == "296-LM358BIDDFRCT-ND" + self.assertEqual(supplier_part.SKU, "296-LM358BIDDFRCT-ND") + + def test_digikey_2_barcode(self): + """Test digikey barcode which uses 30P instead of P""" + result = self.post(self.SCAN_URL, data={"barcode": DIGIKEY_BARCODE_2}, expected_code=200) + self.assertEqual(result.data['plugin'], 'DigiKeyPlugin') + + supplier_part_data = result.data.get("supplierpart") + self.assertIn('pk', supplier_part_data) + + supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"]) + self.assertEqual(supplier_part.SKU, "296-LM358BIDDFRCT-ND") + + def test_digikey_3_barcode(self): + """Test digikey barcode which is invalid""" + self.post(self.SCAN_URL, data={"barcode": DIGIKEY_BARCODE_3}, expected_code=400) def test_mouser_barcode(self): """Test mouser barcode with custom order number.""" - url = reverse("api-barcode-scan") - result = self.post(url, data={"barcode": MOUSER_BARCODE}) + result = self.post(self.SCAN_URL, data={"barcode": MOUSER_BARCODE}, expected_code=200) supplier_part_data = result.data.get("supplierpart") - assert "pk" in supplier_part_data + self.assertIn('pk', supplier_part_data) + supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"]) - assert supplier_part.SKU == "1" + self.assertEqual(supplier_part.SKU, '1') def test_old_mouser_barcode(self): """Test old mouser barcode with messed up header.""" - url = reverse("api-barcode-scan") - result = self.post(url, data={"barcode": MOUSER_BARCODE_OLD}) + result = self.post(self.SCAN_URL, data={"barcode": MOUSER_BARCODE_OLD}, expected_code=200) supplier_part_data = result.data.get("supplierpart") - assert "pk" in supplier_part_data + self.assertIn('pk', supplier_part_data) supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"]) - assert supplier_part.SKU == "2" + self.assertEqual(supplier_part.SKU, '2') def test_lcsc_barcode(self): """Test LCSC barcode.""" - url = reverse("api-barcode-scan") - result = self.post(url, data={"barcode": LCSC_BARCODE}) + result = self.post(self.SCAN_URL, data={"barcode": LCSC_BARCODE}, expected_code=200) + + self.assertEqual(result.data['plugin'], 'LCSCPlugin') supplier_part_data = result.data.get("supplierpart") - assert supplier_part_data is not None + self.assertIn('pk', supplier_part_data) - assert "pk" in supplier_part_data supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"]) - assert supplier_part.SKU == "C312270" + self.assertEqual(supplier_part.SKU, 'C312270') def test_tme_qrcode(self): """Test TME QR-Code.""" - url = reverse("api-barcode-scan") - result = self.post(url, data={"barcode": TME_QRCODE}) + result = self.post(self.SCAN_URL, data={"barcode": TME_QRCODE}, expected_code=200) + + self.assertEqual(result.data['plugin'], 'TMEPlugin') supplier_part_data = result.data.get("supplierpart") - assert supplier_part_data is not None - - assert "pk" in supplier_part_data + self.assertIn('pk', supplier_part_data) supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"]) - assert supplier_part.SKU == "WBP-302" + self.assertEqual(supplier_part.SKU, 'WBP-302') def test_tme_barcode2d(self): """Test TME DataMatrix-Code.""" - url = reverse("api-barcode-scan") - result = self.post(url, data={"barcode": TME_DATAMATRIX_CODE}) + result = self.post(self.SCAN_URL, data={"barcode": TME_DATAMATRIX_CODE}, expected_code=200) + + self.assertEqual(result.data['plugin'], 'TMEPlugin') supplier_part_data = result.data.get("supplierpart") - assert supplier_part_data is not None + self.assertIn('pk', supplier_part_data) - assert "pk" in supplier_part_data supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"]) - assert supplier_part.SKU == "WBP-302" + self.assertEqual(supplier_part.SKU, 'WBP-302') class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase): @@ -161,7 +177,7 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase): result1 = self.post(url, data={"barcode": DIGIKEY_BARCODE}) assert result1.status_code == 400 - assert result1.data["error"].startswith("Failed to find placed purchase order") + assert result1.data["error"].startswith("No matching purchase order") self.purchase_order1.place_order() @@ -273,9 +289,10 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase): url = reverse("api-barcode-po-receive") barcode = MOUSER_BARCODE.replace("\x1dQ3", "") - result = self.post(url, data={"barcode": barcode}) - assert "lineitem" in result.data - assert "quantity" not in result.data["lineitem"] + response = self.post(url, data={"barcode": barcode}, expected_code=200) + + assert "lineitem" in response.data + assert "quantity" not in response.data["lineitem"] DIGIKEY_BARCODE = ( @@ -286,6 +303,25 @@ DIGIKEY_BARCODE = ( "0000000000000000000000000000000000" ) +# Uses 30P instead of P +DIGIKEY_BARCODE_2 = ( + "[)>\x1e06\x1d30P296-LM358BIDDFRCT-ND\x1dK\x1d1K72991337\x1d" + "10K85781337\x1d11K1\x1d4LPH\x1dQ10\x1d11ZPICK\x1d12Z15221337\x1d13Z361337" + "\x1d20Z0000000000000000000000000000000000000000000000000000000000000000000" + "00000000000000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000" +) + +# Invalid code +DIGIKEY_BARCODE_3 = ( + "[)>\x1e06\x1dPnonsense\x1d30Pnonsense\x1d1Pnonsense\x1dK\x1d1K72991337\x1d" + "10K85781337\x1d11K1\x1d4LPH\x1dQ10\x1d11ZPICK\x1d12Z15221337\x1d13Z361337" + "\x1d20Z0000000000000000000000000000000000000000000000000000000000000000000" + "00000000000000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000" + +) + MOUSER_BARCODE = ( "[)>\x1e06\x1dKP0-1337\x1d14K011\x1d1PMC34063ADR\x1dQ3\x1d11K073121337\x1d4" "LMX\x1d1VTI\x1e\x04" @@ -305,4 +341,5 @@ TME_QRCODE = ( "QTY:1 PN:WBP-302 PO:19361337/1 CPO:PO-2023-06-08-001337 MFR:WISHERENTERPRI" "SE MPN:WBP-302 RoHS https://www.tme.eu/details/WBP-302" ) + TME_DATAMATRIX_CODE = "PWBP-302 1PMPNWBP-302 Q1 K19361337/1" diff --git a/InvenTree/plugin/builtin/suppliers/tme.py b/InvenTree/plugin/builtin/suppliers/tme.py index de799a084f..a529553ed1 100644 --- a/InvenTree/plugin/builtin/suppliers/tme.py +++ b/InvenTree/plugin/builtin/suppliers/tme.py @@ -8,7 +8,6 @@ import re from django.utils.translation import gettext_lazy as _ from plugin import InvenTreePlugin -from plugin.base.barcodes.mixins import SupplierBarcodeData from plugin.mixins import SettingsMixin, SupplierBarcodeMixin @@ -30,42 +29,45 @@ class TMEPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin): } } - def parse_supplier_barcode_data(self, barcode_data): + TME_IS_QRCODE_REGEX = re.compile(r"([^\s:]+:[^\s:]+\s+)+(\S+(\s|$)+)+") + TME_IS_BARCODE2D_REGEX = re.compile(r"(([^\s]+)(\s+|$))+") + + # Custom field mapping + TME_QRCODE_FIELDS = { + "PN": SupplierBarcodeMixin.SUPPLIER_PART_NUMBER, + "PO": SupplierBarcodeMixin.CUSTOMER_ORDER_NUMBER, + "MPN": SupplierBarcodeMixin.MANUFACTURER_PART_NUMBER, + "QTY": SupplierBarcodeMixin.QUANTITY, + } + + def extract_barcode_fields(self, barcode_data: str) -> dict[str, str]: """Get supplier_part and barcode_fields from TME QR-Code or DataMatrix-Code.""" - if not isinstance(barcode_data, str): - return None + barcode_fields = {} - if TME_IS_QRCODE_REGEX.fullmatch(barcode_data): - barcode_fields = { - QRCODE_FIELD_NAME_MAP.get(field_name, field_name): value - for field_name, value in TME_PARSE_QRCODE_REGEX.findall(barcode_data) - } - elif TME_IS_BARCODE2D_REGEX.fullmatch(barcode_data): - barcode_fields = self.parse_ecia_barcode2d( - TME_PARSE_BARCODE2D_REGEX.findall(barcode_data) - ) + if self.TME_IS_QRCODE_REGEX.fullmatch(barcode_data): + # Custom QR Code format e.g. "QTY: 1 PN:12345" + for item in barcode_data.split(" "): + if ":" in item: + key, value = item.split(":") + if key in self.TME_QRCODE_FIELDS: + barcode_fields[self.TME_QRCODE_FIELDS[key]] = value + + return barcode_fields + + elif self.TME_IS_BARCODE2D_REGEX.fullmatch(barcode_data): + # 2D Barcode format e.g. "PWBP-302 1PMPNWBP-302 Q1 K19361337/1" + for item in barcode_data.split(" "): + for k, v in self.ecia_field_map().items(): + if item.startswith(k): + barcode_fields[v] = item[len(k):] else: - return None + return {} - if order_number := barcode_fields.get("purchase_order_number"): + # Custom handling for order number + if SupplierBarcodeMixin.CUSTOMER_ORDER_NUMBER in barcode_fields: + order_number = barcode_fields[SupplierBarcodeMixin.CUSTOMER_ORDER_NUMBER] order_number = order_number.split("/")[0] + barcode_fields[SupplierBarcodeMixin.CUSTOMER_ORDER_NUMBER] = order_number - return SupplierBarcodeData( - SKU=barcode_fields.get("supplier_part_number"), - MPN=barcode_fields.get("manufacturer_part_number"), - quantity=barcode_fields.get("quantity"), - order_number=order_number, - ) - - -TME_IS_QRCODE_REGEX = re.compile(r"([^\s:]+:[^\s:]+\s+)+(\S+(\s|$)+)+") -TME_PARSE_QRCODE_REGEX = re.compile(r"([^\s:]+):([^\s:]+)(?:\s+|$)") -TME_IS_BARCODE2D_REGEX = re.compile(r"(([^\s]+)(\s+|$))+") -TME_PARSE_BARCODE2D_REGEX = re.compile(r"([^\s]+)(?:\s+|$)") -QRCODE_FIELD_NAME_MAP = { - "PN": "supplier_part_number", - "PO": "purchase_order_number", - "MPN": "manufacturer_part_number", - "QTY": "quantity", -} + return barcode_fields