2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-11 15:34:15 +00:00

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 <code@mjmair.com>
This commit is contained in:
Bobbe
2023-10-19 14:28:21 +02:00
committed by GitHub
parent 2be5ec26f8
commit ae063d2722
11 changed files with 986 additions and 3 deletions

View File

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

View File

@ -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"^{((?:[^:,]+:[^:,]*,)*(?:[^:,]+:[^:,]*))}$")

View File

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

View File

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

View File

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