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!
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

View File

@ -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
}

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 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)

View File

@ -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

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 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)

View File

@ -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"

View File

@ -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