diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py
index 2618e9d0b5..14c180388c 100644
--- a/InvenTree/InvenTree/api_version.py
+++ b/InvenTree/InvenTree/api_version.py
@@ -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
diff --git a/InvenTree/plugin/base/barcodes/api.py b/InvenTree/plugin/base/barcodes/api.py
index d41113ba67..2f9332daa8 100644
--- a/InvenTree/plugin/base/barcodes/api.py
+++ b/InvenTree/plugin/base/barcodes/api.py
@@ -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'),
]
diff --git a/InvenTree/plugin/base/barcodes/mixins.py b/InvenTree/plugin/base/barcodes/mixins.py
index 03a0345501..bbe6d7d31d 100644
--- a/InvenTree/plugin/base/barcodes/mixins.py
+++ b/InvenTree/plugin/base/barcodes/mixins.py
@@ -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
+}
diff --git a/InvenTree/plugin/builtin/suppliers/__init__.py b/InvenTree/plugin/builtin/suppliers/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/InvenTree/plugin/builtin/suppliers/digikey.py b/InvenTree/plugin/builtin/suppliers/digikey.py
new file mode 100644
index 0000000000..51caa353cd
--- /dev/null
+++ b/InvenTree/plugin/builtin/suppliers/digikey.py
@@ -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"),
+ )
diff --git a/InvenTree/plugin/builtin/suppliers/lcsc.py b/InvenTree/plugin/builtin/suppliers/lcsc.py
new file mode 100644
index 0000000000..c4179767f1
--- /dev/null
+++ b/InvenTree/plugin/builtin/suppliers/lcsc.py
@@ -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"^{((?:[^:,]+:[^:,]*,)*(?:[^:,]+:[^:,]*))}$")
diff --git a/InvenTree/plugin/builtin/suppliers/mouser.py b/InvenTree/plugin/builtin/suppliers/mouser.py
new file mode 100644
index 0000000000..50ad2785c8
--- /dev/null
+++ b/InvenTree/plugin/builtin/suppliers/mouser.py
@@ -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"),
+ )
diff --git a/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py b/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py
new file mode 100644
index 0000000000..fc70500299
--- /dev/null
+++ b/InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py
@@ -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"
diff --git a/InvenTree/plugin/builtin/suppliers/tme.py b/InvenTree/plugin/builtin/suppliers/tme.py
new file mode 100644
index 0000000000..bfcb4c33b5
--- /dev/null
+++ b/InvenTree/plugin/builtin/suppliers/tme.py
@@ -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",
+}
diff --git a/InvenTree/plugin/mixins/__init__.py b/InvenTree/plugin/mixins/__init__.py
index 7c7c05348f..07f6eed485 100644
--- a/InvenTree/plugin/mixins/__init__.py
+++ b/InvenTree/plugin/mixins/__init__.py
@@ -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',
diff --git a/docs/docs/barcodes/custom.md b/docs/docs/barcodes/custom.md
index 55d54998c7..52f5b062cd 100644
--- a/docs/docs/barcodes/custom.md
+++ b/docs/docs/barcodes/custom.md
@@ -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)
diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx
index 50dc588f01..3370f368eb 100644
--- a/src/frontend/src/components/forms/ApiForm.tsx
+++ b/src/frontend/src/components/forms/ApiForm.tsx
@@ -1,11 +1,5 @@
import { t } from '@lingui/macro';
-import {
- Alert,
- Divider,
- LoadingOverlay,
- ScrollArea,
- Text
-} from '@mantine/core';
+import { Alert, Divider, LoadingOverlay, Text } from '@mantine/core';
import { Button, Group, Stack } from '@mantine/core';
import { useForm } from '@mantine/form';
import { modals } from '@mantine/modals';
@@ -277,24 +271,22 @@ export function ApiForm({
)}
{preFormElement}
-
-
- {Object.entries(props.fields ?? {}).map(
- ([fieldName, field]) =>
- !field.hidden && (
-
- )
- )}
-
-
+
+ {Object.entries(props.fields ?? {}).map(
+ ([fieldName, field]) =>
+ !field.hidden && (
+
+ )
+ )}
+
{postFormElement}
diff --git a/src/frontend/src/components/images/Thumbnail.tsx b/src/frontend/src/components/images/Thumbnail.tsx
index 2e7524014c..3798d1ae10 100644
--- a/src/frontend/src/components/images/Thumbnail.tsx
+++ b/src/frontend/src/components/images/Thumbnail.tsx
@@ -19,7 +19,7 @@ export function Thumbnail({
return (
void;
+};
+
+/**
+ * A simple Menu component which renders a set of actions.
+ *
+ * If no "active" actions are provided, the menu will not be rendered
+ */
+export function ActionDropdown({
+ icon,
+ tooltip,
+ actions
+}: {
+ icon: ReactNode;
+ tooltip?: string;
+ actions: ActionDropdownItem[];
+}) {
+ const hasActions = useMemo(() => {
+ return actions.some((action) => !action.disabled);
+ }, [actions]);
+
+ return hasActions ? (
+
+ ) : null;
+}
diff --git a/src/frontend/src/components/nav/Layout.tsx b/src/frontend/src/components/nav/Layout.tsx
index fdcbe6c578..d685a2c6ad 100644
--- a/src/frontend/src/components/nav/Layout.tsx
+++ b/src/frontend/src/components/nav/Layout.tsx
@@ -1,7 +1,8 @@
-import { Container, Flex, Space } from '@mantine/core';
+import { Container, Flex, LoadingOverlay, Space } from '@mantine/core';
import { Navigate, Outlet } from 'react-router-dom';
import { InvenTreeStyle } from '../../globalStyle';
+import { useModalState } from '../../states/ModalState';
import { useSessionState } from '../../states/SessionState';
import { Footer } from './Footer';
import { Header } from './Header';
@@ -19,9 +20,12 @@ export const ProtectedRoute = ({ children }: { children: JSX.Element }) => {
export default function LayoutComponent() {
const { classes } = InvenTreeStyle();
+ const modalState = useModalState();
+
return (
+
diff --git a/src/frontend/src/components/nav/PageDetail.tsx b/src/frontend/src/components/nav/PageDetail.tsx
index be9257f618..6ecd565fcf 100644
--- a/src/frontend/src/components/nav/PageDetail.tsx
+++ b/src/frontend/src/components/nav/PageDetail.tsx
@@ -41,7 +41,11 @@ export function PageDetail({
- {actions && {actions}}
+ {actions && (
+
+ {actions}
+
+ )}
diff --git a/src/frontend/src/components/tables/general/AttachmentTable.tsx b/src/frontend/src/components/tables/general/AttachmentTable.tsx
index 86907aa81f..391be18191 100644
--- a/src/frontend/src/components/tables/general/AttachmentTable.tsx
+++ b/src/frontend/src/components/tables/general/AttachmentTable.tsx
@@ -224,20 +224,22 @@ export function AttachmentTable({
return (
-
+ {pk && pk > 0 && (
+
+ )}
{allowEdit && validPk && (
diff --git a/src/frontend/src/components/tables/general/CompanyTable.tsx b/src/frontend/src/components/tables/general/CompanyTable.tsx
index 5880c9fe8c..2a7cfe0582 100644
--- a/src/frontend/src/components/tables/general/CompanyTable.tsx
+++ b/src/frontend/src/components/tables/general/CompanyTable.tsx
@@ -1,6 +1,7 @@
import { t } from '@lingui/macro';
import { Group, Text } from '@mantine/core';
import { useMemo } from 'react';
+import { useNavigate } from 'react-router-dom';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
@@ -11,9 +12,17 @@ import { InvenTreeTable } from '../InvenTreeTable';
* A table which displays a list of company records,
* based on the provided filter parameters
*/
-export function CompanyTable({ params }: { params?: any }) {
+export function CompanyTable({
+ params,
+ path
+}: {
+ params?: any;
+ path?: string;
+}) {
const { tableKey } = useTableRefresh('company');
+ const navigate = useNavigate();
+
const columns = useMemo(() => {
return [
{
@@ -24,7 +33,7 @@ export function CompanyTable({ params }: { params?: any }) {
return (
@@ -56,6 +65,10 @@ export function CompanyTable({ params }: { params?: any }) {
props={{
params: {
...params
+ },
+ onRowClick: (row: any) => {
+ let base = path ?? 'company';
+ navigate(`/${base}/${row.pk}`);
}
}}
/>
diff --git a/src/frontend/src/functions/forms.tsx b/src/frontend/src/functions/forms.tsx
index a93376fdec..eb46c51145 100644
--- a/src/frontend/src/functions/forms.tsx
+++ b/src/frontend/src/functions/forms.tsx
@@ -7,6 +7,7 @@ import { api } from '../App';
import { ApiForm, ApiFormProps } from '../components/forms/ApiForm';
import { ApiFormFieldType } from '../components/forms/fields/ApiFormField';
import { apiUrl } from '../states/ApiState';
+import { useModalState } from '../states/ModalState';
import { invalidResponse, permissionDenied } from './notifications';
import { generateUniqueId } from './uid';
@@ -97,6 +98,10 @@ export function openModalApiForm(props: ApiFormProps) {
let url = constructFormUrl(props);
+ // let modalState = useModalState();
+
+ useModalState.getState().lock();
+
// Make OPTIONS request first
api
.options(url)
@@ -119,6 +124,7 @@ export function openModalApiForm(props: ApiFormProps) {
modals.open({
title: props.title,
modalId: modalId,
+ size: 'xl',
onClose: () => {
props.onClose ? props.onClose() : null;
},
@@ -126,8 +132,12 @@ export function openModalApiForm(props: ApiFormProps) {
)
});
+
+ useModalState.getState().unlock();
})
.catch((error) => {
+ useModalState.getState().unlock();
+
console.log('Error:', error);
if (error.response) {
invalidResponse(error.response.status);
diff --git a/src/frontend/src/functions/forms/CompanyForms.tsx b/src/frontend/src/functions/forms/CompanyForms.tsx
new file mode 100644
index 0000000000..84a87554f1
--- /dev/null
+++ b/src/frontend/src/functions/forms/CompanyForms.tsx
@@ -0,0 +1,57 @@
+import { t } from '@lingui/macro';
+import {
+ IconAt,
+ IconCurrencyDollar,
+ IconGlobe,
+ IconPhone
+} from '@tabler/icons-react';
+
+import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
+import { ApiPaths } from '../../states/ApiState';
+import { openEditApiForm } from '../forms';
+
+/**
+ * Field set for editing a company instance
+ */
+export function companyFields(): ApiFormFieldSet {
+ return {
+ name: {},
+ description: {},
+ website: {
+ icon:
+ },
+ currency: {
+ icon:
+ },
+ phone: {
+ icon:
+ },
+ email: {
+ icon:
+ },
+ is_supplier: {},
+ is_manufacturer: {},
+ is_customer: {}
+ };
+}
+
+/**
+ * Edit a company instance
+ */
+export function editCompany({
+ pk,
+ callback
+}: {
+ pk: number;
+ callback?: () => void;
+}) {
+ openEditApiForm({
+ name: 'company-edit',
+ title: t`Edit Company`,
+ url: ApiPaths.company_list,
+ pk: pk,
+ fields: companyFields(),
+ successMessage: t`Company updated`,
+ onFormSuccess: callback
+ });
+}
diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx
index b77416aed4..72e60b57eb 100644
--- a/src/frontend/src/pages/build/BuildDetail.tsx
+++ b/src/frontend/src/pages/build/BuildDetail.tsx
@@ -3,16 +3,26 @@ import { Alert, LoadingOverlay, Stack, Text } from '@mantine/core';
import {
IconClipboardCheck,
IconClipboardList,
+ IconCopy,
+ IconDots,
+ IconEdit,
+ IconFileTypePdf,
IconInfoCircle,
+ IconLink,
IconList,
IconListCheck,
IconNotes,
IconPaperclip,
- IconSitemap
+ IconPrinter,
+ IconQrcode,
+ IconSitemap,
+ IconTrash,
+ IconUnlink
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
+import { ActionDropdown } from '../../components/items/ActionDropdown';
import {
PlaceholderPanel,
PlaceholderPill
@@ -25,6 +35,7 @@ import { StockItemTable } from '../../components/tables/stock/StockItemTable';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { useInstance } from '../../hooks/UseInstance';
import { ApiPaths, apiUrl } from '../../states/ApiState';
+import { useUserState } from '../../states/UserState';
/**
* Detail page for a single Build Order
@@ -44,6 +55,8 @@ export default function BuildDetail() {
}
});
+ const user = useUserState();
+
const buildPanels: PanelType[] = useMemo(() => {
return [
{
@@ -130,22 +143,78 @@ export default function BuildDetail() {
];
}, [build]);
+ const buildActions = useMemo(() => {
+ // TODO: Disable certain actions based on user permissions
+ return [
+ }
+ actions={[
+ {
+ icon: ,
+ name: t`View`,
+ tooltip: t`View part barcode`
+ },
+ {
+ icon: ,
+ name: t`Link Barcode`,
+ tooltip: t`Link custom barcode to part`,
+ disabled: build?.barcode_hash
+ },
+ {
+ icon: ,
+ name: t`Unlink Barcode`,
+ tooltip: t`Unlink custom barcode from part`,
+ disabled: !build?.barcode_hash
+ }
+ ]}
+ />,
+ }
+ actions={[
+ {
+ icon: ,
+ name: t`Report`,
+ tooltip: t`Print build report`
+ }
+ ]}
+ />,
+ }
+ actions={[
+ {
+ icon: ,
+ name: t`Edit`,
+ tooltip: t`Edit build order`
+ },
+ {
+ icon: ,
+ name: t`Duplicate`,
+ tooltip: t`Duplicate build order`
+ },
+ {
+ icon: ,
+ name: t`Delete`,
+ tooltip: t`Delete build order`
+ }
+ ]}
+ />
+ ];
+ }, [id, build, user]);
+
return (
<>
- TODO: Build details
-
- }
breadcrumbs={[
{ name: t`Build Orders`, url: '/build' },
{ name: build.reference, url: `/build/${build.pk}` }
]}
- actions={[]}
+ actions={buildActions}
/>
diff --git a/src/frontend/src/pages/company/CompanyDetail.tsx b/src/frontend/src/pages/company/CompanyDetail.tsx
new file mode 100644
index 0000000000..f080fbb6e2
--- /dev/null
+++ b/src/frontend/src/pages/company/CompanyDetail.tsx
@@ -0,0 +1,226 @@
+import { t } from '@lingui/macro';
+import { Group, LoadingOverlay, Stack, Text } from '@mantine/core';
+import {
+ IconBuildingFactory2,
+ IconBuildingWarehouse,
+ IconDots,
+ IconEdit,
+ IconInfoCircle,
+ IconMap2,
+ IconNotes,
+ IconPackageExport,
+ IconPackages,
+ IconPaperclip,
+ IconShoppingCart,
+ IconTrash,
+ IconTruckDelivery,
+ IconTruckReturn,
+ IconUsersGroup
+} from '@tabler/icons-react';
+import { useMemo } from 'react';
+import { useParams } from 'react-router-dom';
+
+import { Thumbnail } from '../../components/images/Thumbnail';
+import { ActionDropdown } from '../../components/items/ActionDropdown';
+import { Breadcrumb } from '../../components/nav/BreadcrumbList';
+import { PageDetail } from '../../components/nav/PageDetail';
+import { PanelGroup } from '../../components/nav/PanelGroup';
+import { PanelType } from '../../components/nav/PanelGroup';
+import { AttachmentTable } from '../../components/tables/general/AttachmentTable';
+import { PurchaseOrderTable } from '../../components/tables/purchasing/PurchaseOrderTable';
+import { ReturnOrderTable } from '../../components/tables/sales/ReturnOrderTable';
+import { SalesOrderTable } from '../../components/tables/sales/SalesOrderTable';
+import { StockItemTable } from '../../components/tables/stock/StockItemTable';
+import { NotesEditor } from '../../components/widgets/MarkdownEditor';
+import { editCompany } from '../../functions/forms/CompanyForms';
+import { useInstance } from '../../hooks/UseInstance';
+import { ApiPaths, apiUrl } from '../../states/ApiState';
+import { useUserState } from '../../states/UserState';
+
+export type CompanyDetailProps = {
+ title: string;
+ breadcrumbs: Breadcrumb[];
+};
+
+/**
+ * Detail view for a single company instance
+ */
+export default function CompanyDetail(props: CompanyDetailProps) {
+ const { id } = useParams();
+
+ const user = useUserState();
+
+ const {
+ instance: company,
+ refreshInstance,
+ instanceQuery
+ } = useInstance({
+ endpoint: ApiPaths.company_list,
+ pk: id,
+ params: {},
+ refetchOnMount: true
+ });
+
+ const companyPanels: PanelType[] = useMemo(() => {
+ return [
+ {
+ name: 'details',
+ label: t`Details`,
+ icon:
+ },
+ {
+ name: 'manufactured-parts',
+ label: t`Manufactured Parts`,
+ icon: ,
+ hidden: !company?.is_manufacturer
+ },
+ {
+ name: 'supplied-parts',
+ label: t`Supplied Parts`,
+ icon: ,
+ hidden: !company?.is_supplier
+ },
+ {
+ name: 'purchase-orders',
+ label: t`Purchase Orders`,
+ icon: ,
+ hidden: !company?.is_supplier,
+ content: company?.pk && (
+
+ )
+ },
+ {
+ name: 'stock-items',
+ label: t`Stock Items`,
+ icon: ,
+ hidden: !company?.is_manufacturer && !company?.is_supplier,
+ content: company?.pk && (
+
+ )
+ },
+ {
+ name: 'sales-orders',
+ label: t`Sales Orders`,
+ icon: ,
+ hidden: !company?.is_customer,
+ content: company?.pk && (
+
+ )
+ },
+ {
+ name: 'return-orders',
+ label: t`Return Orders`,
+ icon: ,
+ hidden: !company?.is_customer,
+ content: company.pk && (
+
+ )
+ },
+ {
+ name: 'assigned-stock',
+ label: t`Assigned Stock`,
+ icon: ,
+ hidden: !company?.is_customer
+ },
+ {
+ name: 'contacts',
+ label: t`Contacts`,
+ icon:
+ },
+ {
+ name: 'addresses',
+ label: t`Addresses`,
+ icon:
+ },
+ {
+ name: 'attachments',
+ label: t`Attachments`,
+ icon: ,
+ content: (
+
+ )
+ },
+ {
+ name: 'notes',
+ label: t`Notes`,
+ icon: ,
+ content: (
+
+ )
+ }
+ ];
+ }, [id, company]);
+
+ const companyDetail = useMemo(() => {
+ return (
+
+
+
+
+ {company.name}
+
+ {company.description}
+
+
+ );
+ }, [id, company]);
+
+ const companyActions = useMemo(() => {
+ // TODO: Finer fidelity on these permissions, perhaps?
+ let canEdit = user.checkUserRole('purchase_order', 'change');
+ let canDelete = user.checkUserRole('purchase_order', 'delete');
+
+ return [
+ }
+ actions={[
+ {
+ icon: ,
+ name: t`Edit`,
+ tooltip: t`Edit company`,
+ disabled: !canEdit,
+ onClick: () => {
+ if (company?.pk) {
+ editCompany({
+ pk: company?.pk,
+ callback: refreshInstance
+ });
+ }
+ }
+ },
+ {
+ icon: ,
+ name: t`Delete`,
+ tooltip: t`Delete company`,
+ disabled: !canDelete
+ }
+ ]}
+ />
+ ];
+ }, [id, company, user]);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/frontend/src/pages/company/CustomerDetail.tsx b/src/frontend/src/pages/company/CustomerDetail.tsx
new file mode 100644
index 0000000000..2725180dbe
--- /dev/null
+++ b/src/frontend/src/pages/company/CustomerDetail.tsx
@@ -0,0 +1,12 @@
+import { t } from '@lingui/macro';
+
+import CompanyDetail from './CompanyDetail';
+
+export default function CustomerDetail() {
+ return (
+
+ );
+}
diff --git a/src/frontend/src/pages/company/ManufacturerDetail.tsx b/src/frontend/src/pages/company/ManufacturerDetail.tsx
new file mode 100644
index 0000000000..aa04b0405c
--- /dev/null
+++ b/src/frontend/src/pages/company/ManufacturerDetail.tsx
@@ -0,0 +1,12 @@
+import { t } from '@lingui/macro';
+
+import CompanyDetail from './CompanyDetail';
+
+export default function ManufacturerDetail() {
+ return (
+
+ );
+}
diff --git a/src/frontend/src/pages/company/SupplierDetail.tsx b/src/frontend/src/pages/company/SupplierDetail.tsx
new file mode 100644
index 0000000000..5be35dda8e
--- /dev/null
+++ b/src/frontend/src/pages/company/SupplierDetail.tsx
@@ -0,0 +1,12 @@
+import { t } from '@lingui/macro';
+
+import CompanyDetail from './CompanyDetail';
+
+export default function SupplierDetail() {
+ return (
+
+ );
+}
diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx
index 7b722191f4..3179a65ae7 100644
--- a/src/frontend/src/pages/part/PartDetail.tsx
+++ b/src/frontend/src/pages/part/PartDetail.tsx
@@ -1,34 +1,37 @@
import { t } from '@lingui/macro';
-import {
- Alert,
- Button,
- Group,
- LoadingOverlay,
- Stack,
- Text
-} from '@mantine/core';
+import { Group, LoadingOverlay, Stack, Text } from '@mantine/core';
import {
IconBuilding,
+ IconCalendarStats,
+ IconClipboardList,
+ IconCopy,
IconCurrencyDollar,
+ IconDots,
+ IconEdit,
IconInfoCircle,
IconLayersLinked,
+ IconLink,
IconList,
IconListTree,
IconNotes,
IconPackages,
IconPaperclip,
+ IconQrcode,
IconShoppingCart,
IconStack2,
IconTestPipe,
IconTools,
+ IconTransfer,
+ IconTrash,
IconTruckDelivery,
+ IconUnlink,
IconVersions
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { ApiImage } from '../../components/images/ApiImage';
-import { PlaceholderPanel } from '../../components/items/Placeholder';
+import { ActionDropdown } from '../../components/items/ActionDropdown';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { AttachmentTable } from '../../components/tables/general/AttachmentTable';
@@ -40,6 +43,7 @@ import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { editPart } from '../../functions/forms/PartForms';
import { useInstance } from '../../hooks/UseInstance';
import { ApiPaths, apiUrl } from '../../states/ApiState';
+import { useUserState } from '../../states/UserState';
/**
* Detail view for a single Part instance
@@ -47,6 +51,8 @@ import { ApiPaths, apiUrl } from '../../states/ApiState';
export default function PartDetail() {
const { id } = useParams();
+ const user = useUserState();
+
const {
instance: part,
refreshInstance,
@@ -66,8 +72,7 @@ export default function PartDetail() {
{
name: 'details',
label: t`Details`,
- icon: ,
- content:
+ icon:
},
{
name: 'parameters',
@@ -98,55 +103,57 @@ export default function PartDetail() {
name: 'bom',
label: t`Bill of Materials`,
icon: ,
- hidden: !part.assembly,
- content:
+ hidden: !part.assembly
},
{
name: 'builds',
label: t`Build Orders`,
icon: ,
- hidden: !part.assembly && !part.component,
- content:
+ hidden: !part.assembly && !part.component
},
{
name: 'used_in',
label: t`Used In`,
icon: ,
- hidden: !part.component,
- content:
+ hidden: !part.component
},
{
name: 'pricing',
label: t`Pricing`,
- icon: ,
- content:
+ icon:
},
{
name: 'suppliers',
label: t`Suppliers`,
icon: ,
- hidden: !part.purchaseable,
- content:
+ hidden: !part.purchaseable
},
{
name: 'purchase_orders',
label: t`Purchase Orders`,
icon: ,
- content: ,
hidden: !part.purchaseable
},
{
name: 'sales_orders',
label: t`Sales Orders`,
icon: ,
- content: ,
hidden: !part.salable
},
+ {
+ name: 'scheduling',
+ label: t`Scheduling`,
+ icon:
+ },
+ {
+ name: 'stocktake',
+ label: t`Stocktake`,
+ icon:
+ },
{
name: 'test_templates',
label: t`Test Templates`,
icon: ,
- content: ,
hidden: !part.trackable
},
{
@@ -212,6 +219,79 @@ export default function PartDetail() {
);
}, [part, id]);
+ const partActions = useMemo(() => {
+ // TODO: Disable actions based on user permissions
+ return [
+ }
+ actions={[
+ {
+ icon: ,
+ name: t`View`,
+ tooltip: t`View part barcode`
+ },
+ {
+ icon: ,
+ name: t`Link Barcode`,
+ tooltip: t`Link custom barcode to part`,
+ disabled: part?.barcode_hash
+ },
+ {
+ icon: ,
+ name: t`Unlink Barcode`,
+ tooltip: t`Unlink custom barcode from part`,
+ disabled: !part?.barcode_hash
+ }
+ ]}
+ />,
+ }
+ actions={[
+ {
+ icon: ,
+ name: t`Count Stock`,
+ tooltip: t`Count part stock`
+ },
+ {
+ icon: ,
+ name: t`Transfer Stock`,
+ tooltip: t`Transfer part stock`
+ }
+ ]}
+ />,
+ }
+ actions={[
+ {
+ icon: ,
+ name: t`Edit`,
+ tooltip: t`Edit part`,
+ onClick: () => {
+ part.pk &&
+ editPart({
+ part_id: part.pk,
+ callback: refreshInstance
+ });
+ }
+ },
+ {
+ icon: ,
+ name: t`Duplicate`,
+ tooltip: t`Duplicate part`
+ },
+ {
+ icon: ,
+ name: t`Delete`,
+ tooltip: t`Delete part`
+ }
+ ]}
+ />
+ ];
+ }, [id, part, user]);
+
return (
<>
@@ -219,21 +299,7 @@ export default function PartDetail() {
- part.pk &&
- editPart({
- part_id: part.pk,
- callback: refreshInstance
- })
- }
- >
- Edit Part
-
- ]}
+ actions={partActions}
/>
diff --git a/src/frontend/src/pages/purchasing/PurchasingIndex.tsx b/src/frontend/src/pages/purchasing/PurchasingIndex.tsx
index 4e2c7969d0..4209d76a1e 100644
--- a/src/frontend/src/pages/purchasing/PurchasingIndex.tsx
+++ b/src/frontend/src/pages/purchasing/PurchasingIndex.tsx
@@ -26,13 +26,23 @@ export default function PurchasingIndex() {
name: 'suppliers',
label: t`Suppliers`,
icon: ,
- content:
+ content: (
+
+ )
},
{
name: 'manufacturer',
label: t`Manufacturers`,
icon: ,
- content:
+ content: (
+
+ )
}
];
}, []);
diff --git a/src/frontend/src/pages/sales/SalesIndex.tsx b/src/frontend/src/pages/sales/SalesIndex.tsx
index 9ed47387f7..0bfe2a5b74 100644
--- a/src/frontend/src/pages/sales/SalesIndex.tsx
+++ b/src/frontend/src/pages/sales/SalesIndex.tsx
@@ -32,7 +32,9 @@ export default function PurchasingIndex() {
name: 'suppliers',
label: t`Customers`,
icon: ,
- content:
+ content: (
+
+ )
}
];
}, []);
diff --git a/src/frontend/src/router.tsx b/src/frontend/src/router.tsx
index 33c1e65119..f67a85ece8 100644
--- a/src/frontend/src/router.tsx
+++ b/src/frontend/src/router.tsx
@@ -12,6 +12,22 @@ export const Playground = Loadable(
lazy(() => import('./pages/Index/Playground'))
);
+export const CompanyDetail = Loadable(
+ lazy(() => import('./pages/company/CompanyDetail'))
+);
+
+export const CustomerDetail = Loadable(
+ lazy(() => import('./pages/company/CustomerDetail'))
+);
+
+export const SupplierDetail = Loadable(
+ lazy(() => import('./pages/company/SupplierDetail'))
+);
+
+export const ManufacturerDetail = Loadable(
+ lazy(() => import('./pages/company/ManufacturerDetail'))
+);
+
export const CategoryDetail = Loadable(
lazy(() => import('./pages/part/CategoryDetail'))
);
@@ -109,9 +125,13 @@ export const routes = (
} />
+ } />
+ } />
+ } />
} />
+ } />
} />
diff --git a/src/frontend/src/states/ApiState.tsx b/src/frontend/src/states/ApiState.tsx
index d5069b1d3b..47fe1bbd5f 100644
--- a/src/frontend/src/states/ApiState.tsx
+++ b/src/frontend/src/states/ApiState.tsx
@@ -55,6 +55,7 @@ export enum ApiPaths {
// Company URLs
company_list = 'api-company-list',
+ company_attachment_list = 'api-company-attachment-list',
supplier_part_list = 'api-supplier-part-list',
// Stock Item URLs
@@ -137,6 +138,8 @@ export function apiEndpoint(path: ApiPaths): string {
return 'part/attachment/';
case ApiPaths.company_list:
return 'company/';
+ case ApiPaths.company_attachment_list:
+ return 'company/attachment/';
case ApiPaths.supplier_part_list:
return 'company/part/';
case ApiPaths.stock_item_list:
diff --git a/src/frontend/src/states/ModalState.tsx b/src/frontend/src/states/ModalState.tsx
new file mode 100644
index 0000000000..6c6e585102
--- /dev/null
+++ b/src/frontend/src/states/ModalState.tsx
@@ -0,0 +1,16 @@
+import { create } from 'zustand';
+
+interface ModalStateProps {
+ loading: boolean;
+ lock: () => void;
+ unlock: () => void;
+}
+
+/**
+ * Global state manager for modal forms.
+ */
+export const useModalState = create((set) => ({
+ loading: false,
+ lock: () => set(() => ({ loading: true })),
+ unlock: () => set(() => ({ loading: false }))
+}));
diff --git a/src/frontend/src/states/UserState.tsx b/src/frontend/src/states/UserState.tsx
index d8edeee061..05ae161660 100644
--- a/src/frontend/src/states/UserState.tsx
+++ b/src/frontend/src/states/UserState.tsx
@@ -10,6 +10,7 @@ interface UserStateProps {
username: () => string;
setUser: (newUser: UserProps) => void;
fetchUserState: () => void;
+ checkUserRole: (role: string, permission: string) => boolean;
}
/**