From ae063d272295306ed096ac770bdb96b8c9d3db09 Mon Sep 17 00:00:00 2001 From: Bobbe <34186858+30350n@users.noreply.github.com> Date: Thu, 19 Oct 2023 14:28:21 +0200 Subject: [PATCH] Add basic support for scanning vendor barcodes (#5509) * Add support for scanning digikey and mouser barcodes * added small fixes if no part is found * made small style cleanups * Separate out ecia 2d barcode parser, Add quantity, PO number to response * Use model instead of name for mouser supplier, add auto detection magic * Add lcsc barcode support * Move barcode plugins to new suppliers subdirectory * Add get_supplier_part helper, Refactor plugins in preparation for #3791 * Add __init__.py to suppliers directory * Improve formatting * Add barcode integration tests * Add api-barcode-po-receive endpoint * Refactor supplier_barcode.py helpers into BarcodeMixin * Implement the api-barcode-po-receive endpoint for all suppliers * Always include lineitem in api response * Fix location in response, only include quantity and location if set * Check if barcode has already been assigned, Fix tests * FIx quantity and location not being in lineitem reponse * Use part.get_default_location() instead of part.default_location * Fix fomatting again * Fix type annotations for python 3.8 * Add get_supplier_part helper, check for barcode_data being a str * Fix naming clash * Clarify return type for scan_receive_item * Improve model access using first() in two places * Refactor a bunch of checks * Improve selection of line item, if multiple line items match the SKU * Add new api version for this PR * Fix error if no line item exists * Add debug print to investigate why tests are failing * Remove the test print again * Fix pre formatted log messages * Test removing all plugins * Test only with digikey plugin * Test with all plugins, but without mouser "model" setting * Test again without tests * Test with simple tests * Test with simple receive test * Test with even more receive tests * Test second receive test * Test third receive test * Test 4th receive test with debug prints * Try deleting the stock item and stock locations * Disable the test again * Add SupplierBarcodeMixin to minimize shared code between plugins * Add TME supplier barcode plugin * Remove the TME tests again * If this works the tests are broken, if this doesn't work the tests are broken too * Add TME tests again * Add back all tests again * Fix TME purchase order number * Fix TME qrcode regex * Add documentation for this feature * Fix TME qrcode regex * Use Decimal instead of int for quantity * Refactor get_supplier_parts, Add get_supplier method * Improve docstrings * Fix None type access * FIx TME barcode detection, Improve supplier barcode handling * Try to retrigger pipeline * Refactor get_supplier_parts to not use lists * Add DEFAULT_SUPPLIER_NAME to mouser plugin * Add SUPPLIER_ID setting to other suppliers * Fix supplier plugins not inheriting from settings mixin --------- Co-authored-by: Matthias Mair --- InvenTree/InvenTree/api_version.py | 5 +- InvenTree/plugin/base/barcodes/api.py | 85 ++++- InvenTree/plugin/base/barcodes/mixins.py | 348 ++++++++++++++++++ .../plugin/builtin/suppliers/__init__.py | 0 InvenTree/plugin/builtin/suppliers/digikey.py | 49 +++ InvenTree/plugin/builtin/suppliers/lcsc.py | 55 +++ InvenTree/plugin/builtin/suppliers/mouser.py | 49 +++ .../suppliers/test_supplier_barcodes.py | 308 ++++++++++++++++ InvenTree/plugin/builtin/suppliers/tme.py | 74 ++++ InvenTree/plugin/mixins/__init__.py | 3 +- docs/docs/barcodes/custom.md | 13 + 11 files changed, 986 insertions(+), 3 deletions(-) create mode 100644 InvenTree/plugin/builtin/suppliers/__init__.py create mode 100644 InvenTree/plugin/builtin/suppliers/digikey.py create mode 100644 InvenTree/plugin/builtin/suppliers/lcsc.py create mode 100644 InvenTree/plugin/builtin/suppliers/mouser.py create mode 100644 InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py create mode 100644 InvenTree/plugin/builtin/suppliers/tme.py diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 2618e9d0b5..14c180388c 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,14 @@ # InvenTree API version -INVENTREE_API_VERSION = 138 +INVENTREE_API_VERSION = 139 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v139 -> 2023-10-11 : https://github.com/inventree/InvenTree/pull/5509 + - Add new BarcodePOReceive endpoint to receive line items by scanning supplier barcodes + v138 -> 2023-10-11 : https://github.com/inventree/InvenTree/pull/5679 - Settings keys are no longer case sensitive - Include settings units in API serializer diff --git a/InvenTree/plugin/base/barcodes/api.py b/InvenTree/plugin/base/barcodes/api.py index d41113ba67..2f9332daa8 100644 --- a/InvenTree/plugin/base/barcodes/api.py +++ b/InvenTree/plugin/base/barcodes/api.py @@ -10,9 +10,11 @@ from rest_framework.response import Response from rest_framework.views import APIView from InvenTree.helpers import hash_barcode +from order.models import PurchaseOrder from plugin import registry from plugin.builtin.barcodes.inventree_barcode import \ InvenTreeInternalBarcodePlugin +from stock.models import StockLocation from users.models import RuleSet @@ -230,7 +232,7 @@ class BarcodeUnassign(APIView): instance.unassign_barcode() return Response({ - 'success': 'Barcode unassigned from {label} instance', + 'success': f'Barcode unassigned from {label} instance', }) # If we get to this point, something has gone wrong! @@ -239,6 +241,84 @@ class BarcodeUnassign(APIView): }) +class BarcodePOReceive(APIView): + """Endpoint for handling receiving parts by scanning their barcode. + + Barcode data are decoded by the client application, + and sent to this endpoint (as a JSON object) for validation. + + The barcode should follow a third-party barcode format (e.g. Digikey) + and ideally contain order_number and quantity information. + + The following parameters are available: + + - barcode: The raw barcode data (required) + - purchase_order: The purchase order containing the item to receive (optional) + - location: The destination location for the received item (optional) + """ + + permission_classes = [ + permissions.IsAuthenticated, + ] + + def post(self, request, *args, **kwargs): + """Respond to a barcode POST request.""" + + data = request.data + if not (barcode_data := data.get("barcode")): + raise ValidationError({"barcode": _("Missing barcode data")}) + + purchase_order = None + if purchase_order_pk := data.get("purchase_order"): + purchase_order = PurchaseOrder.objects.filter(pk=purchase_order_pk).first() + if not purchase_order: + raise ValidationError({"purchase_order": _("Invalid purchase order")}) + + location = None + if (location_pk := data.get("location")): + location = StockLocation.objects.get(pk=location_pk) + if not location: + raise ValidationError({"location": _("Invalid stock location")}) + + plugins = registry.with_mixin("barcode") + + # Look for a barcode plugin which knows how to deal with this barcode + plugin = None + response = {} + + internal_barcode_plugin = next(filter( + lambda plugin: plugin.name == "InvenTreeBarcode", plugins)) + if internal_barcode_plugin.scan(barcode_data): + response["error"] = _("Item has already been received") + raise ValidationError(response) + + for current_plugin in plugins: + result = current_plugin.scan_receive_item( + barcode_data, + request.user, + purchase_order=purchase_order, + location=location, + ) + + if result is not None: + plugin = current_plugin + response = result + break + + response["plugin"] = plugin.name if plugin else None + response["barcode_data"] = barcode_data + response["barcode_hash"] = hash_barcode(barcode_data) + + # A plugin has not been found! + if plugin is None: + response["error"] = _("Invalid supplier barcode") + raise ValidationError(response) + elif "error" in response: + raise ValidationError(response) + else: + return Response(response) + + 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'), @@ -246,6 +326,9 @@ barcode_api_urls = [ # Unlink a third-pary barcode from an item path('unlink/', BarcodeUnassign.as_view(), name='api-barcode-unlink'), + # Receive a purchase order item by scanning its barcode + path("po-receive/", BarcodePOReceive.as_view(), name="api-barcode-po-receive"), + # Catch-all performs barcode 'scan' re_path(r'^.*$', BarcodeScan.as_view(), name='api-barcode-scan'), ] diff --git a/InvenTree/plugin/base/barcodes/mixins.py b/InvenTree/plugin/base/barcodes/mixins.py index 03a0345501..bbe6d7d31d 100644 --- a/InvenTree/plugin/base/barcodes/mixins.py +++ b/InvenTree/plugin/base/barcodes/mixins.py @@ -1,5 +1,22 @@ """Plugin mixin classes for barcode plugin.""" +from __future__ import annotations + +import logging +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation + +from django.contrib.auth.models import User +from django.db.models import F +from django.utils.translation import gettext_lazy as _ + +from company.models import Company, SupplierPart +from order.models import PurchaseOrder, PurchaseOrderStatus +from plugin.base.integration.SettingsMixin import SettingsMixin +from stock.models import StockLocation + +logger = logging.getLogger('inventree') + class BarcodeMixin: """Mixin that enables barcode handling. @@ -36,3 +53,334 @@ class BarcodeMixin: Default return value is None """ return None + + def scan_receive_item(self, barcode_data, user, purchase_order=None, location=None): + """Scan a barcode to receive a purchase order item. + + It's recommended to use the receive_purchase_order_item method to return from this function. + + Returns: + None if the barcode_data could not be parsed. + + A dict object containing: + - on success: + a "success" message and the received "lineitem" + - on partial success (if there's missing information): + an "action_required" message and the matched, but not yet received "lineitem" + - on failure: + an "error" message + """ + + return None + + @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""" + + if not isinstance(barcode_data, str): + data_split = barcode_data + elif not (data_split := BarcodeMixin.parse_isoiec_15434_barcode2d(barcode_data)): + return None + + 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):] + break + + return barcode_fields + + @staticmethod + def parse_isoiec_15434_barcode2d(barcode_data: str) -> list[str]: + """Parse a ISO/IEC 15434 bardode, returning the split data section.""" + HEADER = "[)>\x1E06\x1D" + TRAILER = "\x1E\x04" + + # some old mouser barcodes start with this messed up header + OLD_MOUSER_HEADER = ">[)>06\x1D" + 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 + if not barcode_data.startswith(HEADER): + return + + actual_data = barcode_data.split(HEADER, 1)[1].rsplit(TRAILER, 1)[0] + + return actual_data.split("\x1D") + + @staticmethod + def get_supplier_parts(sku: str, 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() + + supplier_parts = SupplierPart.objects.all() + + if sku: + supplier_parts = supplier_parts.filter(SKU__iexact=sku) + if len(supplier_parts) == 1: + return supplier_parts + + if supplier: + supplier_parts = supplier_parts.filter(supplier=supplier.pk) + if len(supplier_parts) == 1: + return supplier_parts + + if mpn: + supplier_parts = SupplierPart.objects.filter(manufacturer_part__MPN__iexact=mpn) + if len(supplier_parts) == 1: + return supplier_parts + + logger.warning( + "Found %d supplier parts for SKU '%s', supplier '%s', MPN '%s'", + supplier_parts.count(), + sku, + supplier.name if supplier else None, + mpn, + ) + + return supplier_parts + + @staticmethod + def receive_purchase_order_item( + supplier_part: SupplierPart, + user: User, + quantity: Decimal | str = None, + order_number: str = None, + purchase_order: PurchaseOrder = None, + location: StockLocation = None, + barcode: str = None, + ) -> dict: + """Try to receive a purchase order item. + + Returns: + A dict object containing: + - on success: a "success" message + - on partial success: the "lineitem" with quantity and location (both can be None) + - 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) + except InvalidOperation: + logger.warning("Failed to parse quantity '%s'", quantity) + quantity = None + + # find incomplete line_items that match the supplier_part + line_items = purchase_order.lines.filter( + part=supplier_part.pk, quantity__gt=F("received")) + if len(line_items) == 1 or not quantity: + line_item = line_items[0] + else: + # if there are multiple line items and the barcode contains a quantity: + # 1. return the first line_item where line_item.quantity == quantity + # 2. return the first line_item where line_item.quantity > quantity + # 3. return the first line_item + for line_item in line_items: + if line_item.quantity == quantity: + break + else: + for line_item in line_items: + if line_item.quantity > quantity: + break + else: + line_item = line_items.first() + + if not line_item: + return {"error": _("Failed to find pending line item for supplier part")} + + no_stock_locations = False + if not location: + # try to guess the destination were the stock_part should go + # 1. check if it's defined on the line_item + # 2. check if it's defined on the part + # 3. check if there's 1 or 0 stock locations defined in InvenTree + # -> assume all stock is going into that location (or no location) + if location := line_item.destination: + pass + elif location := supplier_part.part.get_default_location(): + pass + elif StockLocation.objects.count() <= 1: + if not (location := StockLocation.objects.first()): + no_stock_locations = True + + response = { + "lineitem": { + "pk": line_item.pk, + "purchase_order": purchase_order.pk, + } + } + + if quantity: + response["lineitem"]["quantity"] = quantity + if location: + response["lineitem"]["location"] = location.pk + + # if either the quantity is missing or no location is defined/found + # -> return the line_item found, so the client can gather the missing + # information and complete the action with an 'api-po-receive' call + if not quantity or (not location and not no_stock_locations): + response["action_required"] = _("Further information required to receive line item") + return response + + purchase_order.receive_line_item( + line_item, + location, + quantity, + user, + barcode=barcode, + ) + + response["success"] = _("Received purchase order line item") + return response + + +@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. + """ + + def parse_supplier_barcode_data(self, barcode_data) -> SupplierBarcodeData | None: + """Get supplier_part and other barcode_fields from barcode data. + + Returns: + None if the barcode_data is not from a valid barcode of the supplier. + + A SupplierBarcodeData object containing the SKU, MPN, quantity and order number + if available. + """ + + return None + + 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: + return None + + supplier_parts = self.get_supplier_parts(parsed.SKU, self.get_supplier(), parsed.MPN) + 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 = { + "pk": supplier_part.pk, + "api_url": f"{SupplierPart.get_api_url()}{supplier_part.pk}/", + "web_url": supplier_part.get_absolute_url(), + } + + 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: + return None + + supplier_parts = self.get_supplier_parts(parsed.SKU, self.get_supplier(), parsed.MPN) + 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] + + return self.receive_purchase_order_item( + supplier_part, + user, + quantity=parsed.quantity, + order_number=parsed.order_number, + purchase_order=purchase_order, + location=location, + barcode=barcode_data, + ) + + def get_supplier(self) -> Company | None: + """Get the supplier for the SUPPLIER_ID set in the plugin settings. + + If it's not defined, try to guess it and set it if possible. + """ + + if not isinstance(self, SettingsMixin): + return None + + if supplier_pk := self.get_setting("SUPPLIER_ID"): + if (supplier := Company.objects.get(pk=supplier_pk)): + return supplier + else: + logger.error( + "No company with pk %d (set \"SUPPLIER_ID\" setting to a valid value)", + supplier_pk + ) + return None + + if not (supplier_name := getattr(self, "DEFAULT_SUPPLIER_NAME", None)): + 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() + + +# 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/__init__.py b/InvenTree/plugin/builtin/suppliers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/InvenTree/plugin/builtin/suppliers/digikey.py b/InvenTree/plugin/builtin/suppliers/digikey.py new file mode 100644 index 0000000000..51caa353cd --- /dev/null +++ b/InvenTree/plugin/builtin/suppliers/digikey.py @@ -0,0 +1,49 @@ +"""The DigiKeyPlugin is meant to integrate the DigiKey API into Inventree. + +This plugin can currently only match DigiKey barcodes to supplier parts. +""" + +import logging + +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 + +logger = logging.getLogger('inventree') + + +class DigiKeyPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin): + """Plugin to integrate the DigiKey API into Inventree.""" + + NAME = "DigiKeyPlugin" + TITLE = _("Supplier Integration - DigiKey") + DESCRIPTION = _("Provides support for scanning DigiKey barcodes") + VERSION = "1.0.0" + AUTHOR = _("InvenTree contributors") + + DEFAULT_SUPPLIER_NAME = "DigiKey" + SETTINGS = { + "SUPPLIER_ID": { + "name": _("Supplier"), + "description": _("The Supplier which acts as 'DigiKey'"), + "model": "company.company", + } + } + + def parse_supplier_barcode_data(self, barcode_data): + """Get supplier_part and barcode_fields from DigiKey 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"), + ) diff --git a/InvenTree/plugin/builtin/suppliers/lcsc.py b/InvenTree/plugin/builtin/suppliers/lcsc.py new file mode 100644 index 0000000000..c4179767f1 --- /dev/null +++ b/InvenTree/plugin/builtin/suppliers/lcsc.py @@ -0,0 +1,55 @@ +"""The LCSCPlugin is meant to integrate the LCSC API into Inventree. + +This plugin can currently only match LCSC barcodes to supplier parts. +""" + +import logging +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 + +logger = logging.getLogger('inventree') + + +class LCSCPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin): + """Plugin to integrate the LCSC API into Inventree.""" + + NAME = "LCSCPlugin" + TITLE = _("Supplier Integration - LCSC") + DESCRIPTION = _("Provides support for scanning LCSC barcodes") + VERSION = "1.0.0" + AUTHOR = _("InvenTree contributors") + + DEFAULT_SUPPLIER_NAME = "LCSC" + SETTINGS = { + "SUPPLIER_ID": { + "name": _("Supplier"), + "description": _("The Supplier which acts as 'LCSC'"), + "model": "company.company", + } + } + + def parse_supplier_barcode_data(self, barcode_data): + """Get supplier_part and barcode_fields from LCSC QR-Code.""" + + if not isinstance(barcode_data, str): + return None + + if not (match := LCSC_BARCODE_REGEX.fullmatch(barcode_data)): + return None + + barcode_fields = dict(pair.split(":") for pair in match.group(1).split(",")) + + return SupplierBarcodeData( + SKU=barcode_fields.get("pc"), + MPN=barcode_fields.get("pm"), + quantity=barcode_fields.get("qty"), + order_number=barcode_fields.get("on"), + ) + + +LCSC_BARCODE_REGEX = re.compile(r"^{((?:[^:,]+:[^:,]*,)*(?:[^:,]+:[^:,]*))}$") diff --git a/InvenTree/plugin/builtin/suppliers/mouser.py b/InvenTree/plugin/builtin/suppliers/mouser.py new file mode 100644 index 0000000000..50ad2785c8 --- /dev/null +++ b/InvenTree/plugin/builtin/suppliers/mouser.py @@ -0,0 +1,49 @@ +"""The MouserPlugin is meant to integrate the Mouser API into Inventree. + +This plugin currently only match Mouser barcodes to supplier parts. +""" + +import logging + +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 + +logger = logging.getLogger('inventree') + + +class MouserPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin): + """Plugin to integrate the Mouser API into Inventree.""" + + NAME = "MouserPlugin" + TITLE = _("Supplier Integration - Mouser") + DESCRIPTION = _("Provides support for scanning Mouser barcodes") + VERSION = "1.0.0" + AUTHOR = _("InvenTree contributors") + + DEFAULT_SUPPLIER_NAME = "Mouser" + SETTINGS = { + "SUPPLIER_ID": { + "name": _("Supplier"), + "description": _("The Supplier which acts as 'Mouser'"), + "model": "company.company", + } + } + + def parse_supplier_barcode_data(self, barcode_data): + """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"), + ) diff --git a/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py b/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py new file mode 100644 index 0000000000..fc70500299 --- /dev/null +++ b/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py @@ -0,0 +1,308 @@ +"""Tests barcode parsing for all suppliers.""" + +from django.urls import reverse + +from company.models import Company, ManufacturerPart, SupplierPart +from InvenTree.unit_test import InvenTreeAPITestCase +from order.models import PurchaseOrder, PurchaseOrderLineItem +from part.models import Part +from stock.models import StockItem, StockLocation + + +class SupplierBarcodeTests(InvenTreeAPITestCase): + """Tests barcode parsing for all suppliers.""" + + @classmethod + def setUpTestData(cls): + """Create supplier parts for barcodes.""" + super().setUpTestData() + + part = Part.objects.create(name="Test Part", description="Test Part") + + manufacturer = Company.objects.create( + name="Test Manufacturer", is_manufacturer=True) + + mpart1 = ManufacturerPart.objects.create( + part=part, manufacturer=manufacturer, MPN="MC34063ADR") + mpart2 = ManufacturerPart.objects.create( + part=part, manufacturer=manufacturer, MPN="LDK320ADU33R") + + supplier = Company.objects.create(name="Supplier", is_supplier=True) + mouser = Company.objects.create(name="Mouser Test", is_supplier=True) + + supplier_parts = [ + SupplierPart(SKU="296-LM358BIDDFRCT-ND", part=part, supplier=supplier), + SupplierPart(SKU="1", part=part, manufacturer_part=mpart1, supplier=mouser), + SupplierPart(SKU="2", part=part, manufacturer_part=mpart2, supplier=mouser), + SupplierPart(SKU="C312270", part=part, supplier=supplier), + SupplierPart(SKU="WBP-302", part=part, supplier=supplier), + ] + + SupplierPart.objects.bulk_create(supplier_parts) + + def test_digikey_barcode(self): + """Test digikey barcode.""" + + url = reverse("api-barcode-scan") + result = self.post(url, data={"barcode": DIGIKEY_BARCODE}) + + supplier_part_data = result.data.get("supplierpart") + assert "pk" in supplier_part_data + supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"]) + assert supplier_part.SKU == "296-LM358BIDDFRCT-ND" + + 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}) + + supplier_part_data = result.data.get("supplierpart") + assert "pk" in supplier_part_data + supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"]) + assert 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}) + + supplier_part_data = result.data.get("supplierpart") + assert "pk" in supplier_part_data + supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"]) + assert 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}) + + supplier_part_data = result.data.get("supplierpart") + assert supplier_part_data is not None + + assert "pk" in supplier_part_data + supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"]) + assert 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}) + + supplier_part_data = result.data.get("supplierpart") + assert supplier_part_data is not None + + assert "pk" in supplier_part_data + supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"]) + assert 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}) + + supplier_part_data = result.data.get("supplierpart") + assert supplier_part_data is not None + + assert "pk" in supplier_part_data + supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"]) + assert supplier_part.SKU == "WBP-302" + + +class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase): + """Tests barcode scanning to receive a purchase order item.""" + + def setUp(self): + """Create supplier part and purchase_order.""" + super().setUp() + + part = Part.objects.create(name="Test Part", description="Test Part") + supplier = Company.objects.create(name="Supplier", is_supplier=True) + manufacturer = Company.objects.create( + name="Test Manufacturer", is_manufacturer=True) + + mouser = Company.objects.create(name="Mouser Test", is_supplier=True) + mpart = ManufacturerPart.objects.create( + part=part, manufacturer=manufacturer, MPN="MC34063ADR") + + self.purchase_order1 = PurchaseOrder.objects.create( + supplier_reference="72991337", supplier=supplier) + supplier_parts1 = [ + SupplierPart(SKU=f"1_{i}", part=part, supplier=supplier) + for i in range(6) + ] + supplier_parts1.insert( + 2, SupplierPart(SKU="296-LM358BIDDFRCT-ND", part=part, supplier=supplier)) + for supplier_part in supplier_parts1: + supplier_part.save() + self.purchase_order1.add_line_item(supplier_part, 8) + + self.purchase_order2 = PurchaseOrder.objects.create( + reference="P0-1337", supplier=mouser) + self.purchase_order2.place_order() + supplier_parts2 = [ + SupplierPart(SKU=f"2_{i}", part=part, supplier=mouser) + for i in range(6) + ] + supplier_parts2.insert( + 3, SupplierPart(SKU="42", part=part, manufacturer_part=mpart, supplier=mouser)) + for supplier_part in supplier_parts2: + supplier_part.save() + self.purchase_order2.add_line_item(supplier_part, 5) + + def test_receive(self): + """Test receiving an item from a barcode.""" + + url = reverse("api-barcode-po-receive") + + result1 = self.post(url, data={"barcode": DIGIKEY_BARCODE}) + assert result1.status_code == 400 + assert result1.data["error"].startswith("Failed to find placed purchase order") + + self.purchase_order1.place_order() + + result2 = self.post(url, data={"barcode": DIGIKEY_BARCODE}) + assert result2.status_code == 200 + assert "success" in result2.data + + result3 = self.post(url, data={"barcode": DIGIKEY_BARCODE}) + assert result3.status_code == 400 + assert result3.data["error"].startswith( + "Item has already been received") + + result4 = self.post(url, data={"barcode": DIGIKEY_BARCODE[:-1]}) + assert result4.status_code == 400 + assert result4.data["error"].startswith( + "Failed to find pending line item for supplier part") + + result5 = self.post(reverse("api-barcode-scan"), data={"barcode": DIGIKEY_BARCODE}) + assert result5.status_code == 200 + stock_item = StockItem.objects.get(pk=result5.data["stockitem"]["pk"]) + assert stock_item.supplier_part.SKU == "296-LM358BIDDFRCT-ND" + assert stock_item.quantity == 10 + assert stock_item.location is None + + def test_receive_custom_order_number(self): + """Test receiving an item from a barcode with a custom order number.""" + + url = reverse("api-barcode-po-receive") + result1 = self.post(url, data={"barcode": MOUSER_BARCODE}) + assert "success" in result1.data + + result2 = self.post(reverse("api-barcode-scan"), data={"barcode": MOUSER_BARCODE}) + stock_item = StockItem.objects.get(pk=result2.data["stockitem"]["pk"]) + assert stock_item.supplier_part.SKU == "42" + assert stock_item.supplier_part.manufacturer_part.MPN == "MC34063ADR" + assert stock_item.quantity == 3 + assert stock_item.location is None + + def test_receive_one_stock_location(self): + """Test receiving an item when only one stock location exists""" + + stock_location = StockLocation.objects.create(name="Test Location") + + url = reverse("api-barcode-po-receive") + result1 = self.post(url, data={"barcode": MOUSER_BARCODE}) + assert "success" in result1.data + + result2 = self.post(reverse("api-barcode-scan"), data={"barcode": MOUSER_BARCODE}) + stock_item = StockItem.objects.get(pk=result2.data["stockitem"]["pk"]) + assert stock_item.location == stock_location + + def test_receive_default_line_item_location(self): + """Test receiving an item into the default line_item location""" + + StockLocation.objects.create(name="Test Location 1") + stock_location2 = StockLocation.objects.create(name="Test Location 2") + + line_item = PurchaseOrderLineItem.objects.filter(part__SKU="42")[0] + line_item.destination = stock_location2 + line_item.save() + + url = reverse("api-barcode-po-receive") + result1 = self.post(url, data={"barcode": MOUSER_BARCODE}) + assert "success" in result1.data + + result2 = self.post(reverse("api-barcode-scan"), data={"barcode": MOUSER_BARCODE}) + stock_item = StockItem.objects.get(pk=result2.data["stockitem"]["pk"]) + assert stock_item.location == stock_location2 + + def test_receive_default_part_location(self): + """Test receiving an item into the default part location""" + + StockLocation.objects.create(name="Test Location 1") + stock_location2 = StockLocation.objects.create(name="Test Location 2") + + part = Part.objects.all()[0] + part.default_location = stock_location2 + part.save() + + url = reverse("api-barcode-po-receive") + result1 = self.post(url, data={"barcode": MOUSER_BARCODE}) + assert "success" in result1.data + + result2 = self.post(reverse("api-barcode-scan"), data={"barcode": MOUSER_BARCODE}) + stock_item = StockItem.objects.get(pk=result2.data["stockitem"]["pk"]) + assert stock_item.location == stock_location2 + + def test_receive_specific_order_and_location(self): + """Test receiving an item from a specific order into a specific location""" + + StockLocation.objects.create(name="Test Location 1") + stock_location2 = StockLocation.objects.create(name="Test Location 2") + + url = reverse("api-barcode-po-receive") + barcode = MOUSER_BARCODE.replace("\x1dKP0-1337", "") + result1 = self.post(url, data={ + "barcode": barcode, + "purchase_order": self.purchase_order2.pk, + "location": stock_location2.pk, + }) + assert "success" in result1.data + + result2 = self.post(reverse("api-barcode-scan"), data={"barcode": barcode}) + stock_item = StockItem.objects.get(pk=result2.data["stockitem"]["pk"]) + assert stock_item.location == stock_location2 + + def test_receive_missing_quantity(self): + """Test receiving an with missing quantity information""" + + 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"] + + +DIGIKEY_BARCODE = ( + "[)>\x1e06\x1dP296-LM358BIDDFRCT-ND\x1d1PLM358BIDDFR\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" +) + +MOUSER_BARCODE_OLD = ( + ">[)>06\x1dK21421337\x1d14K033\x1d1PLDK320ADU33R\x1dQ32\x1d11K060931337\x1d" + "4LCN\x1d1VSTMicro" +) + +LCSC_BARCODE = ( + "{pbn:PICK2009291337,on:SO2009291337,pc:C312270,pm:ST-1-102-A01-T000-RS,qty" + ":2,mc:,cc:1,pdi:34421807}" +) + +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 new file mode 100644 index 0000000000..bfcb4c33b5 --- /dev/null +++ b/InvenTree/plugin/builtin/suppliers/tme.py @@ -0,0 +1,74 @@ +"""The TMEPlugin is meant to integrate the TME API into Inventree. + +This plugin can currently only match TME barcodes to supplier parts. +""" + +import logging +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 + +logger = logging.getLogger('inventree') + + +class TMEPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin): + """Plugin to integrate the TME API into Inventree.""" + + NAME = "TMEPlugin" + TITLE = _("Supplier Integration - TME") + DESCRIPTION = _("Provides support for scanning TME barcodes") + VERSION = "1.0.0" + AUTHOR = _("InvenTree contributors") + + DEFAULT_SUPPLIER_NAME = "TME" + SETTINGS = { + "SUPPLIER_ID": { + "name": _("Supplier"), + "description": _("The Supplier which acts as 'TME'"), + "model": "company.company", + } + } + + def parse_supplier_barcode_data(self, barcode_data): + """Get supplier_part and barcode_fields from TME QR-Code or DataMatrix-Code.""" + + if not isinstance(barcode_data, str): + return None + + 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) + ) + else: + return None + + if order_number := barcode_fields.get("purchase_order_number"): + order_number = order_number.split("/")[0] + + 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", +} diff --git a/InvenTree/plugin/mixins/__init__.py b/InvenTree/plugin/mixins/__init__.py index 7c7c05348f..07f6eed485 100644 --- a/InvenTree/plugin/mixins/__init__.py +++ b/InvenTree/plugin/mixins/__init__.py @@ -3,7 +3,7 @@ from common.notifications import (BulkNotificationMethod, SingleNotificationMethod) from plugin.base.action.mixins import ActionMixin -from plugin.base.barcodes.mixins import BarcodeMixin +from plugin.base.barcodes.mixins import BarcodeMixin, SupplierBarcodeMixin from plugin.base.event.mixins import EventMixin from plugin.base.integration.APICallMixin import APICallMixin from plugin.base.integration.AppMixin import AppMixin @@ -33,6 +33,7 @@ __all__ = [ 'PanelMixin', 'ActionMixin', 'BarcodeMixin', + 'SupplierBarcodeMixin', 'LocateMixin', 'ValidationMixin', 'SingleNotificationMethod', diff --git a/docs/docs/barcodes/custom.md b/docs/docs/barcodes/custom.md index 55d54998c7..52f5b062cd 100644 --- a/docs/docs/barcodes/custom.md +++ b/docs/docs/barcodes/custom.md @@ -26,3 +26,16 @@ The barcode is tested as follows, in decreasing order of priority: !!! tip "Plugin Loading Order" The first custom plugin to return a result "wins". As the loading order of custom plugins is not defined (or configurable), take special care if you are running multiple plugins which support barcode actions. + +## Builtin Supplier Barcode Plugins + +InvenTree comes with a few builtin supplier plugins, which handle their respective barcode formats. + +Scanning a supplier barcode for a supplied part will link to the corresponding supplier part if the [SKU](../report/context_variables.md#supplierpart) from the barcode could be matched. + +The following suppliers (and barcode formats) are currently supported: + +- DigiKey (2D Data Matrix code) +- Mouser (2D Data Matrix code) +- LCSC (QR code) +- TME (QR code & 2D Data Matrix code)