mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
538a01c500
commit
df0da18d2f
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user