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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 986 additions and 3 deletions

View File

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

View File

@ -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'),
]

View File

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

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

View File

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

View File

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