2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-29 12:06:44 +00:00

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
This commit is contained in:
Oliver 2023-11-15 23:35:31 +11:00 committed by GitHub
parent 538a01c500
commit df0da18d2f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 343 additions and 206 deletions

View File

@ -340,7 +340,7 @@ class BarcodePOReceive(APIView):
# A plugin has not been found! # A plugin has not been found!
if plugin is None: if plugin is None:
response["error"] = _("Invalid supplier barcode") response["error"] = _("No match for supplier barcode")
raise ValidationError(response) raise ValidationError(response)
elif "error" in response: elif "error" in response:
raise ValidationError(response) raise ValidationError(response)
@ -352,7 +352,7 @@ barcode_api_urls = [
# Link a third-party barcode to an item (e.g. Part / StockItem / etc) # Link a third-party barcode to an item (e.g. Part / StockItem / etc)
path('link/', BarcodeAssign.as_view(), name='api-barcode-link'), 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'), path('unlink/', BarcodeUnassign.as_view(), name='api-barcode-unlink'),
# Receive a purchase order item by scanning its barcode # Receive a purchase order item by scanning its barcode

View File

@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -55,52 +54,99 @@ class BarcodeMixin:
return None 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): class SupplierBarcodeMixin(BarcodeMixin):
"""Mixin that provides default implementations for scan functions for supplier barcodes. """Mixin that provides default implementations for scan functions for supplier barcodes.
Custom supplier barcode plugins should use this mixin and implement the 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): def __init__(self):
"""Register mixin.""" """Register mixin."""
super().__init__() super().__init__()
self.add_mixin('supplier-barcode', True, __class__) self.add_mixin('supplier-barcode', True, __class__)
def parse_supplier_barcode_data(self, barcode_data) -> SupplierBarcodeData | None: def get_field_value(self, key, backup_value=None):
"""Get supplier_part and other barcode_fields from barcode data. """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: 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.
""" """
raise NotImplementedError("extract_barcode_fields must be implemented by each plugin")
return None
def scan(self, barcode_data): def scan(self, barcode_data):
"""Try to match a supplier barcode to a supplier part.""" """Try to match a supplier barcode to a supplier part."""
if not (parsed := self.parse_supplier_barcode_data(barcode_data)): barcode_data = str(barcode_data).strip()
return None
if parsed.SKU is None and parsed.MPN is None: 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 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: if len(supplier_parts) > 1:
return {"error": _("Found multiple matching supplier parts for barcode")} return {"error": _("Found multiple matching supplier parts for barcode")}
elif not supplier_parts: elif not supplier_parts:
return None return None
supplier_part = supplier_parts[0] supplier_part = supplier_parts[0]
data = { data = {
@ -109,28 +155,61 @@ class SupplierBarcodeMixin(BarcodeMixin):
"web_url": supplier_part.get_absolute_url(), "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): 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.""" """Try to scan a supplier barcode to receive a purchase order item."""
if not (parsed := self.parse_supplier_barcode_data(barcode_data)): barcode_data = str(barcode_data).strip()
return None
if parsed.SKU is None and parsed.MPN is None: 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 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: if len(supplier_parts) > 1:
return {"error": _("Found multiple matching supplier parts for barcode")} return {"error": _("Found multiple matching supplier parts for barcode")}
elif not supplier_parts: elif not supplier_parts:
return None return None
supplier_part = supplier_parts[0] 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( return self.receive_purchase_order_item(
supplier_part, supplier_part,
user, user,
quantity=parsed.quantity, quantity=self.quantity,
order_number=parsed.order_number,
purchase_order=purchase_order, purchase_order=purchase_order,
location=location, location=location,
barcode=barcode_data, barcode=barcode_data,
@ -159,52 +238,124 @@ class SupplierBarcodeMixin(BarcodeMixin):
return None return None
suppliers = Company.objects.filter(name__icontains=supplier_name, is_supplier=True) suppliers = Company.objects.filter(name__icontains=supplier_name, is_supplier=True)
if len(suppliers) != 1: if len(suppliers) != 1:
return None return None
self.set_setting("SUPPLIER_ID", suppliers.first().pk) self.set_setting("SUPPLIER_ID", suppliers.first().pk)
return suppliers.first() return suppliers.first()
@staticmethod @classmethod
def parse_ecia_barcode2d(barcode_data: str | list[str]) -> dict[str, str]: def ecia_field_map(cls):
"""Parse a standard ECIA 2D barcode, according to https://www.ecianow.org/assets/docs/ECIA_Specifications.pdf""" """Return a dict mapping ECIA field names to internal field names
if not isinstance(barcode_data, str): Ref: https://www.ecianow.org/assets/docs/ECIA_Specifications.pdf
data_split = barcode_data
elif not (data_split := SupplierBarcodeMixin.parse_isoiec_15434_barcode2d(barcode_data)): Note that a particular plugin may need to reimplement this method,
return None 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 = {} barcode_fields = {}
for entry in data_split:
for identifier, field_name in ECIA_DATA_IDENTIFIER_MAP.items(): if not fields:
if entry.startswith(identifier): return barcode_fields
barcode_fields[field_name] = entry[len(identifier):]
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 break
return barcode_fields 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 @staticmethod
def parse_isoiec_15434_barcode2d(barcode_data: str) -> list[str]: 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" HEADER = "[)>\x1E06\x1D"
TRAILER = "\x1E\x04" TRAILER = "\x1E\x04"
DELIMITER = "\x1D"
# some old mouser barcodes start with this messed up header # Some old mouser barcodes start with this messed up header
OLD_MOUSER_HEADER = ">[)>06\x1D"
if barcode_data.startswith(OLD_MOUSER_HEADER): if barcode_data.startswith(OLD_MOUSER_HEADER):
barcode_data = barcode_data.replace(OLD_MOUSER_HEADER, HEADER, 1) barcode_data = barcode_data.replace(OLD_MOUSER_HEADER, HEADER, 1)
# most barcodes don't include the trailer, because "why would you stick to # Check that the barcode starts with the necessary header
# the standard, right?" so we only check for the header here
if not barcode_data.startswith(HEADER): if not barcode_data.startswith(HEADER):
return return
actual_data = barcode_data.split(HEADER, 1)[1].rsplit(TRAILER, 1)[0] return SupplierBarcodeMixin.split_fields(
barcode_data,
return actual_data.split("\x1D") delimiter=DELIMITER,
header=HEADER,
trailer=TRAILER,
)
@staticmethod @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.""" """Get a supplier part from SKU or by supplier and MPN."""
if not (sku or supplier or mpn): if not (sku or supplier or mpn):
return SupplierPart.objects.none() return SupplierPart.objects.none()
@ -241,7 +392,6 @@ class SupplierBarcodeMixin(BarcodeMixin):
supplier_part: SupplierPart, supplier_part: SupplierPart,
user: User, user: User,
quantity: Decimal | str = None, quantity: Decimal | str = None,
order_number: str = None,
purchase_order: PurchaseOrder = None, purchase_order: PurchaseOrder = None,
location: StockLocation = None, location: StockLocation = None,
barcode: str = None, barcode: str = None,
@ -255,27 +405,6 @@ class SupplierBarcodeMixin(BarcodeMixin):
- on failure: an "error" message - 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: if quantity:
try: try:
quantity = Decimal(quantity) quantity = Decimal(quantity)
@ -350,23 +479,3 @@ class SupplierBarcodeMixin(BarcodeMixin):
response["success"] = _("Received purchase order line item") response["success"] = _("Received purchase order line item")
return response 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
}

View File

@ -6,7 +6,6 @@ This plugin can currently only match DigiKey barcodes to supplier parts.
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from plugin import InvenTreePlugin from plugin import InvenTreePlugin
from plugin.base.barcodes.mixins import SupplierBarcodeData
from plugin.mixins import SettingsMixin, SupplierBarcodeMixin from plugin.mixins import SettingsMixin, SupplierBarcodeMixin
@ -20,6 +19,7 @@ class DigiKeyPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin):
AUTHOR = _("InvenTree contributors") AUTHOR = _("InvenTree contributors")
DEFAULT_SUPPLIER_NAME = "DigiKey" DEFAULT_SUPPLIER_NAME = "DigiKey"
SETTINGS = { SETTINGS = {
"SUPPLIER_ID": { "SUPPLIER_ID": {
"name": _("Supplier"), "name": _("Supplier"),
@ -28,22 +28,7 @@ class DigiKeyPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin):
} }
} }
def parse_supplier_barcode_data(self, barcode_data): def extract_barcode_fields(self, barcode_data) -> dict[str, str]:
"""Get supplier_part and barcode_fields from DigiKey DataMatrix-Code.""" """Extract barcode fields from a DigiKey plugin"""
if not isinstance(barcode_data, str): return self.parse_ecia_barcode2d(barcode_data)
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"),
)

View File

@ -8,7 +8,6 @@ import re
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from plugin import InvenTreePlugin from plugin import InvenTreePlugin
from plugin.base.barcodes.mixins import SupplierBarcodeData
from plugin.mixins import SettingsMixin, SupplierBarcodeMixin from plugin.mixins import SettingsMixin, SupplierBarcodeMixin
@ -30,23 +29,40 @@ class LCSCPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin):
} }
} }
def parse_supplier_barcode_data(self, barcode_data): LCSC_BARCODE_REGEX = re.compile(r"^{((?:[^:,]+:[^:,]*,)*(?:[^:,]+:[^:,]*))}$")
"""Get supplier_part and barcode_fields from LCSC QR-Code."""
if not isinstance(barcode_data, str): # Custom field mapping for LCSC barcodes
return None 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)): def extract_barcode_fields(self, barcode_data: str) -> dict[str, str]:
return None """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( if not self.LCSC_BARCODE_REGEX.fullmatch(barcode_data):
SKU=barcode_fields.get("pc"), return {}
MPN=barcode_fields.get("pm"),
quantity=barcode_fields.get("qty"), # Extract fields
order_number=barcode_fields.get("on"), 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

View File

@ -6,7 +6,6 @@ This plugin currently only match Mouser barcodes to supplier parts.
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from plugin import InvenTreePlugin from plugin import InvenTreePlugin
from plugin.base.barcodes.mixins import SupplierBarcodeData
from plugin.mixins import SettingsMixin, SupplierBarcodeMixin 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.""" """Get supplier_part and barcode_fields from Mouser DataMatrix-Code."""
if not isinstance(barcode_data, str): return self.parse_ecia_barcode2d(barcode_data)
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"),
)

View File

@ -12,6 +12,8 @@ from stock.models import StockItem, StockLocation
class SupplierBarcodeTests(InvenTreeAPITestCase): class SupplierBarcodeTests(InvenTreeAPITestCase):
"""Tests barcode parsing for all suppliers.""" """Tests barcode parsing for all suppliers."""
SCAN_URL = reverse("api-barcode-scan")
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
"""Create supplier parts for barcodes.""" """Create supplier parts for barcodes."""
@ -41,76 +43,90 @@ class SupplierBarcodeTests(InvenTreeAPITestCase):
SupplierPart.objects.bulk_create(supplier_parts) SupplierPart.objects.bulk_create(supplier_parts)
def test_digikey_barcode(self): def test_digikey_barcode(self):
"""Test digikey barcode.""" """Test digikey barcode"""
url = reverse("api-barcode-scan") result = self.post(self.SCAN_URL, data={"barcode": DIGIKEY_BARCODE}, expected_code=200)
result = self.post(url, data={"barcode": DIGIKEY_BARCODE}) self.assertEqual(result.data['plugin'], 'DigiKeyPlugin')
supplier_part_data = result.data.get("supplierpart") 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"]) 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): def test_mouser_barcode(self):
"""Test mouser barcode with custom order number.""" """Test mouser barcode with custom order number."""
url = reverse("api-barcode-scan") result = self.post(self.SCAN_URL, data={"barcode": MOUSER_BARCODE}, expected_code=200)
result = self.post(url, data={"barcode": MOUSER_BARCODE})
supplier_part_data = result.data.get("supplierpart") 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"]) 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): def test_old_mouser_barcode(self):
"""Test old mouser barcode with messed up header.""" """Test old mouser barcode with messed up header."""
url = reverse("api-barcode-scan") result = self.post(self.SCAN_URL, data={"barcode": MOUSER_BARCODE_OLD}, expected_code=200)
result = self.post(url, data={"barcode": MOUSER_BARCODE_OLD})
supplier_part_data = result.data.get("supplierpart") 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"]) 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): def test_lcsc_barcode(self):
"""Test LCSC barcode.""" """Test LCSC barcode."""
url = reverse("api-barcode-scan") result = self.post(self.SCAN_URL, data={"barcode": LCSC_BARCODE}, expected_code=200)
result = self.post(url, data={"barcode": LCSC_BARCODE})
self.assertEqual(result.data['plugin'], 'LCSCPlugin')
supplier_part_data = result.data.get("supplierpart") 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"]) 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): def test_tme_qrcode(self):
"""Test TME QR-Code.""" """Test TME QR-Code."""
url = reverse("api-barcode-scan") result = self.post(self.SCAN_URL, data={"barcode": TME_QRCODE}, expected_code=200)
result = self.post(url, data={"barcode": TME_QRCODE})
self.assertEqual(result.data['plugin'], 'TMEPlugin')
supplier_part_data = result.data.get("supplierpart") 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"]) 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): def test_tme_barcode2d(self):
"""Test TME DataMatrix-Code.""" """Test TME DataMatrix-Code."""
url = reverse("api-barcode-scan") result = self.post(self.SCAN_URL, data={"barcode": TME_DATAMATRIX_CODE}, expected_code=200)
result = self.post(url, data={"barcode": TME_DATAMATRIX_CODE})
self.assertEqual(result.data['plugin'], 'TMEPlugin')
supplier_part_data = result.data.get("supplierpart") 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"]) 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): class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
@ -161,7 +177,7 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
result1 = self.post(url, data={"barcode": DIGIKEY_BARCODE}) result1 = self.post(url, data={"barcode": DIGIKEY_BARCODE})
assert result1.status_code == 400 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() self.purchase_order1.place_order()
@ -273,9 +289,10 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
url = reverse("api-barcode-po-receive") url = reverse("api-barcode-po-receive")
barcode = MOUSER_BARCODE.replace("\x1dQ3", "") barcode = MOUSER_BARCODE.replace("\x1dQ3", "")
result = self.post(url, data={"barcode": barcode}) response = self.post(url, data={"barcode": barcode}, expected_code=200)
assert "lineitem" in result.data
assert "quantity" not in result.data["lineitem"] assert "lineitem" in response.data
assert "quantity" not in response.data["lineitem"]
DIGIKEY_BARCODE = ( DIGIKEY_BARCODE = (
@ -286,6 +303,25 @@ DIGIKEY_BARCODE = (
"0000000000000000000000000000000000" "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 = ( MOUSER_BARCODE = (
"[)>\x1e06\x1dKP0-1337\x1d14K011\x1d1PMC34063ADR\x1dQ3\x1d11K073121337\x1d4" "[)>\x1e06\x1dKP0-1337\x1d14K011\x1d1PMC34063ADR\x1dQ3\x1d11K073121337\x1d4"
"LMX\x1d1VTI\x1e\x04" "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" "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" "SE MPN:WBP-302 RoHS https://www.tme.eu/details/WBP-302"
) )
TME_DATAMATRIX_CODE = "PWBP-302 1PMPNWBP-302 Q1 K19361337/1" TME_DATAMATRIX_CODE = "PWBP-302 1PMPNWBP-302 Q1 K19361337/1"

View File

@ -8,7 +8,6 @@ import re
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from plugin import InvenTreePlugin from plugin import InvenTreePlugin
from plugin.base.barcodes.mixins import SupplierBarcodeData
from plugin.mixins import SettingsMixin, SupplierBarcodeMixin 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.""" """Get supplier_part and barcode_fields from TME QR-Code or DataMatrix-Code."""
if not isinstance(barcode_data, str): barcode_fields = {}
return None
if TME_IS_QRCODE_REGEX.fullmatch(barcode_data): if self.TME_IS_QRCODE_REGEX.fullmatch(barcode_data):
barcode_fields = { # Custom QR Code format e.g. "QTY: 1 PN:12345"
QRCODE_FIELD_NAME_MAP.get(field_name, field_name): value for item in barcode_data.split(" "):
for field_name, value in TME_PARSE_QRCODE_REGEX.findall(barcode_data) if ":" in item:
} key, value = item.split(":")
elif TME_IS_BARCODE2D_REGEX.fullmatch(barcode_data): if key in self.TME_QRCODE_FIELDS:
barcode_fields = self.parse_ecia_barcode2d( barcode_fields[self.TME_QRCODE_FIELDS[key]] = value
TME_PARSE_BARCODE2D_REGEX.findall(barcode_data)
) 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: 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] order_number = order_number.split("/")[0]
barcode_fields[SupplierBarcodeMixin.CUSTOMER_ORDER_NUMBER] = order_number
return SupplierBarcodeData( return barcode_fields
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",
}