Merge branch 'inventree:master' into matmair/issue5729

This commit is contained in:
Matthias Mair 2023-10-19 16:51:46 +02:00 committed by GitHub
commit 481cd70aca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1675 additions and 96 deletions

View File

@ -2,11 +2,14 @@
# InvenTree API version
INVENTREE_API_VERSION = 138
INVENTREE_API_VERSION = 139
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v139 -> 2023-10-11 : https://github.com/inventree/InvenTree/pull/5509
- Add new BarcodePOReceive endpoint to receive line items by scanning supplier barcodes
v138 -> 2023-10-11 : https://github.com/inventree/InvenTree/pull/5679
- Settings keys are no longer case sensitive
- Include settings units in API serializer

View File

@ -10,9 +10,11 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from InvenTree.helpers import hash_barcode
from order.models import PurchaseOrder
from plugin import registry
from plugin.builtin.barcodes.inventree_barcode import \
InvenTreeInternalBarcodePlugin
from stock.models import StockLocation
from users.models import RuleSet
@ -230,7 +232,7 @@ class BarcodeUnassign(APIView):
instance.unassign_barcode()
return Response({
'success': 'Barcode unassigned from {label} instance',
'success': f'Barcode unassigned from {label} instance',
})
# If we get to this point, something has gone wrong!
@ -239,6 +241,84 @@ class BarcodeUnassign(APIView):
})
class BarcodePOReceive(APIView):
"""Endpoint for handling receiving parts by scanning their barcode.
Barcode data are decoded by the client application,
and sent to this endpoint (as a JSON object) for validation.
The barcode should follow a third-party barcode format (e.g. Digikey)
and ideally contain order_number and quantity information.
The following parameters are available:
- barcode: The raw barcode data (required)
- purchase_order: The purchase order containing the item to receive (optional)
- location: The destination location for the received item (optional)
"""
permission_classes = [
permissions.IsAuthenticated,
]
def post(self, request, *args, **kwargs):
"""Respond to a barcode POST request."""
data = request.data
if not (barcode_data := data.get("barcode")):
raise ValidationError({"barcode": _("Missing barcode data")})
purchase_order = None
if purchase_order_pk := data.get("purchase_order"):
purchase_order = PurchaseOrder.objects.filter(pk=purchase_order_pk).first()
if not purchase_order:
raise ValidationError({"purchase_order": _("Invalid purchase order")})
location = None
if (location_pk := data.get("location")):
location = StockLocation.objects.get(pk=location_pk)
if not location:
raise ValidationError({"location": _("Invalid stock location")})
plugins = registry.with_mixin("barcode")
# Look for a barcode plugin which knows how to deal with this barcode
plugin = None
response = {}
internal_barcode_plugin = next(filter(
lambda plugin: plugin.name == "InvenTreeBarcode", plugins))
if internal_barcode_plugin.scan(barcode_data):
response["error"] = _("Item has already been received")
raise ValidationError(response)
for current_plugin in plugins:
result = current_plugin.scan_receive_item(
barcode_data,
request.user,
purchase_order=purchase_order,
location=location,
)
if result is not None:
plugin = current_plugin
response = result
break
response["plugin"] = plugin.name if plugin else None
response["barcode_data"] = barcode_data
response["barcode_hash"] = hash_barcode(barcode_data)
# A plugin has not been found!
if plugin is None:
response["error"] = _("Invalid supplier barcode")
raise ValidationError(response)
elif "error" in response:
raise ValidationError(response)
else:
return Response(response)
barcode_api_urls = [
# Link a third-party barcode to an item (e.g. Part / StockItem / etc)
path('link/', BarcodeAssign.as_view(), name='api-barcode-link'),
@ -246,6 +326,9 @@ barcode_api_urls = [
# Unlink a third-pary barcode from an item
path('unlink/', BarcodeUnassign.as_view(), name='api-barcode-unlink'),
# Receive a purchase order item by scanning its barcode
path("po-receive/", BarcodePOReceive.as_view(), name="api-barcode-po-receive"),
# Catch-all performs barcode 'scan'
re_path(r'^.*$', BarcodeScan.as_view(), name='api-barcode-scan'),
]

View File

@ -1,5 +1,22 @@
"""Plugin mixin classes for barcode plugin."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from django.contrib.auth.models import User
from django.db.models import F
from django.utils.translation import gettext_lazy as _
from company.models import Company, SupplierPart
from order.models import PurchaseOrder, PurchaseOrderStatus
from plugin.base.integration.SettingsMixin import SettingsMixin
from stock.models import StockLocation
logger = logging.getLogger('inventree')
class BarcodeMixin:
"""Mixin that enables barcode handling.
@ -36,3 +53,334 @@ class BarcodeMixin:
Default return value is None
"""
return None
def scan_receive_item(self, barcode_data, user, purchase_order=None, location=None):
"""Scan a barcode to receive a purchase order item.
It's recommended to use the receive_purchase_order_item method to return from this function.
Returns:
None if the barcode_data could not be parsed.
A dict object containing:
- on success:
a "success" message and the received "lineitem"
- on partial success (if there's missing information):
an "action_required" message and the matched, but not yet received "lineitem"
- on failure:
an "error" message
"""
return None
@staticmethod
def parse_ecia_barcode2d(barcode_data: str | list[str]) -> dict[str, str]:
"""Parse a standard ECIA 2D barcode, according to https://www.ecianow.org/assets/docs/ECIA_Specifications.pdf"""
if not isinstance(barcode_data, str):
data_split = barcode_data
elif not (data_split := BarcodeMixin.parse_isoiec_15434_barcode2d(barcode_data)):
return None
barcode_fields = {}
for entry in data_split:
for identifier, field_name in ECIA_DATA_IDENTIFIER_MAP.items():
if entry.startswith(identifier):
barcode_fields[field_name] = entry[len(identifier):]
break
return barcode_fields
@staticmethod
def parse_isoiec_15434_barcode2d(barcode_data: str) -> list[str]:
"""Parse a ISO/IEC 15434 bardode, returning the split data section."""
HEADER = "[)>\x1E06\x1D"
TRAILER = "\x1E\x04"
# some old mouser barcodes start with this messed up header
OLD_MOUSER_HEADER = ">[)>06\x1D"
if barcode_data.startswith(OLD_MOUSER_HEADER):
barcode_data = barcode_data.replace(OLD_MOUSER_HEADER, HEADER, 1)
# most barcodes don't include the trailer, because "why would you stick to
# the standard, right?" so we only check for the header here
if not barcode_data.startswith(HEADER):
return
actual_data = barcode_data.split(HEADER, 1)[1].rsplit(TRAILER, 1)[0]
return actual_data.split("\x1D")
@staticmethod
def get_supplier_parts(sku: str, supplier: Company = None, mpn: str = None):
"""Get a supplier part from SKU or by supplier and MPN."""
if not (sku or supplier or mpn):
return SupplierPart.objects.none()
supplier_parts = SupplierPart.objects.all()
if sku:
supplier_parts = supplier_parts.filter(SKU__iexact=sku)
if len(supplier_parts) == 1:
return supplier_parts
if supplier:
supplier_parts = supplier_parts.filter(supplier=supplier.pk)
if len(supplier_parts) == 1:
return supplier_parts
if mpn:
supplier_parts = SupplierPart.objects.filter(manufacturer_part__MPN__iexact=mpn)
if len(supplier_parts) == 1:
return supplier_parts
logger.warning(
"Found %d supplier parts for SKU '%s', supplier '%s', MPN '%s'",
supplier_parts.count(),
sku,
supplier.name if supplier else None,
mpn,
)
return supplier_parts
@staticmethod
def receive_purchase_order_item(
supplier_part: SupplierPart,
user: User,
quantity: Decimal | str = None,
order_number: str = None,
purchase_order: PurchaseOrder = None,
location: StockLocation = None,
barcode: str = None,
) -> dict:
"""Try to receive a purchase order item.
Returns:
A dict object containing:
- on success: a "success" message
- on partial success: the "lineitem" with quantity and location (both can be None)
- on failure: an "error" message
"""
if not purchase_order:
# try to find a purchase order with either reference or name matching
# the provided order_number
if not order_number:
return {"error": _("Supplier barcode doesn't contain order number")}
purchase_orders = (
PurchaseOrder.objects.filter(
supplier_reference__iexact=order_number,
status=PurchaseOrderStatus.PLACED.value,
) | PurchaseOrder.objects.filter(
reference__iexact=order_number,
status=PurchaseOrderStatus.PLACED.value,
)
)
if len(purchase_orders) > 1:
return {"error": _(f"Found multiple placed purchase orders for '{order_number}'")}
elif not (purchase_order := purchase_orders.first()):
return {"error": _(f"Failed to find placed purchase order for '{order_number}'")}
if quantity:
try:
quantity = Decimal(quantity)
except InvalidOperation:
logger.warning("Failed to parse quantity '%s'", quantity)
quantity = None
# find incomplete line_items that match the supplier_part
line_items = purchase_order.lines.filter(
part=supplier_part.pk, quantity__gt=F("received"))
if len(line_items) == 1 or not quantity:
line_item = line_items[0]
else:
# if there are multiple line items and the barcode contains a quantity:
# 1. return the first line_item where line_item.quantity == quantity
# 2. return the first line_item where line_item.quantity > quantity
# 3. return the first line_item
for line_item in line_items:
if line_item.quantity == quantity:
break
else:
for line_item in line_items:
if line_item.quantity > quantity:
break
else:
line_item = line_items.first()
if not line_item:
return {"error": _("Failed to find pending line item for supplier part")}
no_stock_locations = False
if not location:
# try to guess the destination were the stock_part should go
# 1. check if it's defined on the line_item
# 2. check if it's defined on the part
# 3. check if there's 1 or 0 stock locations defined in InvenTree
# -> assume all stock is going into that location (or no location)
if location := line_item.destination:
pass
elif location := supplier_part.part.get_default_location():
pass
elif StockLocation.objects.count() <= 1:
if not (location := StockLocation.objects.first()):
no_stock_locations = True
response = {
"lineitem": {
"pk": line_item.pk,
"purchase_order": purchase_order.pk,
}
}
if quantity:
response["lineitem"]["quantity"] = quantity
if location:
response["lineitem"]["location"] = location.pk
# if either the quantity is missing or no location is defined/found
# -> return the line_item found, so the client can gather the missing
# information and complete the action with an 'api-po-receive' call
if not quantity or (not location and not no_stock_locations):
response["action_required"] = _("Further information required to receive line item")
return response
purchase_order.receive_line_item(
line_item,
location,
quantity,
user,
barcode=barcode,
)
response["success"] = _("Received purchase order line item")
return response
@dataclass
class SupplierBarcodeData:
"""Data parsed from a supplier barcode."""
SKU: str = None
MPN: str = None
quantity: Decimal | str = None
order_number: str = None
class SupplierBarcodeMixin(BarcodeMixin):
"""Mixin that provides default implementations for scan functions for supplier barcodes.
Custom supplier barcode plugins should use this mixin and implement the
parse_supplier_barcode_data function.
"""
def parse_supplier_barcode_data(self, barcode_data) -> SupplierBarcodeData | None:
"""Get supplier_part and other barcode_fields from barcode data.
Returns:
None if the barcode_data is not from a valid barcode of the supplier.
A SupplierBarcodeData object containing the SKU, MPN, quantity and order number
if available.
"""
return None
def scan(self, barcode_data):
"""Try to match a supplier barcode to a supplier part."""
if not (parsed := self.parse_supplier_barcode_data(barcode_data)):
return None
if parsed.SKU is None and parsed.MPN is None:
return None
supplier_parts = self.get_supplier_parts(parsed.SKU, self.get_supplier(), parsed.MPN)
if len(supplier_parts) > 1:
return {"error": _("Found multiple matching supplier parts for barcode")}
elif not supplier_parts:
return None
supplier_part = supplier_parts[0]
data = {
"pk": supplier_part.pk,
"api_url": f"{SupplierPart.get_api_url()}{supplier_part.pk}/",
"web_url": supplier_part.get_absolute_url(),
}
return {SupplierPart.barcode_model_type(): data}
def scan_receive_item(self, barcode_data, user, purchase_order=None, location=None):
"""Try to scan a supplier barcode to receive a purchase order item."""
if not (parsed := self.parse_supplier_barcode_data(barcode_data)):
return None
if parsed.SKU is None and parsed.MPN is None:
return None
supplier_parts = self.get_supplier_parts(parsed.SKU, self.get_supplier(), parsed.MPN)
if len(supplier_parts) > 1:
return {"error": _("Found multiple matching supplier parts for barcode")}
elif not supplier_parts:
return None
supplier_part = supplier_parts[0]
return self.receive_purchase_order_item(
supplier_part,
user,
quantity=parsed.quantity,
order_number=parsed.order_number,
purchase_order=purchase_order,
location=location,
barcode=barcode_data,
)
def get_supplier(self) -> Company | None:
"""Get the supplier for the SUPPLIER_ID set in the plugin settings.
If it's not defined, try to guess it and set it if possible.
"""
if not isinstance(self, SettingsMixin):
return None
if supplier_pk := self.get_setting("SUPPLIER_ID"):
if (supplier := Company.objects.get(pk=supplier_pk)):
return supplier
else:
logger.error(
"No company with pk %d (set \"SUPPLIER_ID\" setting to a valid value)",
supplier_pk
)
return None
if not (supplier_name := getattr(self, "DEFAULT_SUPPLIER_NAME", None)):
return None
suppliers = Company.objects.filter(name__icontains=supplier_name, is_supplier=True)
if len(suppliers) != 1:
return None
self.set_setting("SUPPLIER_ID", suppliers.first().pk)
return suppliers.first()
# Map ECIA Data Identifier to human readable identifier
# The following identifiers haven't been implemented: 3S, 4S, 5S, S
ECIA_DATA_IDENTIFIER_MAP = {
"K": "purchase_order_number", # noqa: E241
"1K": "purchase_order_number", # noqa: E241 DigiKey uses 1K instead of K
"11K": "packing_list_number", # noqa: E241
"6D": "ship_date", # noqa: E241
"P": "supplier_part_number", # noqa: E241 "Customer Part Number"
"1P": "manufacturer_part_number", # noqa: E241 "Supplier Part Number"
"4K": "purchase_order_line", # noqa: E241
"14K": "purchase_order_line", # noqa: E241 Mouser uses 14K instead of 4K
"Q": "quantity", # noqa: E241
"9D": "date_yyww", # noqa: E241
"10D": "date_yyww", # noqa: E241
"1T": "lot_code", # noqa: E241
"4L": "country_of_origin", # noqa: E241
"1V": "manufacturer" # noqa: E241
}

View File

@ -0,0 +1,49 @@
"""The DigiKeyPlugin is meant to integrate the DigiKey API into Inventree.
This plugin can currently only match DigiKey barcodes to supplier parts.
"""
import logging
from django.utils.translation import gettext_lazy as _
from plugin import InvenTreePlugin
from plugin.base.barcodes.mixins import SupplierBarcodeData
from plugin.mixins import SettingsMixin, SupplierBarcodeMixin
logger = logging.getLogger('inventree')
class DigiKeyPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin):
"""Plugin to integrate the DigiKey API into Inventree."""
NAME = "DigiKeyPlugin"
TITLE = _("Supplier Integration - DigiKey")
DESCRIPTION = _("Provides support for scanning DigiKey barcodes")
VERSION = "1.0.0"
AUTHOR = _("InvenTree contributors")
DEFAULT_SUPPLIER_NAME = "DigiKey"
SETTINGS = {
"SUPPLIER_ID": {
"name": _("Supplier"),
"description": _("The Supplier which acts as 'DigiKey'"),
"model": "company.company",
}
}
def parse_supplier_barcode_data(self, barcode_data):
"""Get supplier_part and barcode_fields from DigiKey DataMatrix-Code."""
if not isinstance(barcode_data, str):
return None
if not (barcode_fields := self.parse_ecia_barcode2d(barcode_data)):
return None
return SupplierBarcodeData(
SKU=barcode_fields.get("supplier_part_number"),
MPN=barcode_fields.get("manufacturer_part_number"),
quantity=barcode_fields.get("quantity"),
order_number=barcode_fields.get("purchase_order_number"),
)

View File

@ -0,0 +1,55 @@
"""The LCSCPlugin is meant to integrate the LCSC API into Inventree.
This plugin can currently only match LCSC barcodes to supplier parts.
"""
import logging
import re
from django.utils.translation import gettext_lazy as _
from plugin import InvenTreePlugin
from plugin.base.barcodes.mixins import SupplierBarcodeData
from plugin.mixins import SettingsMixin, SupplierBarcodeMixin
logger = logging.getLogger('inventree')
class LCSCPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin):
"""Plugin to integrate the LCSC API into Inventree."""
NAME = "LCSCPlugin"
TITLE = _("Supplier Integration - LCSC")
DESCRIPTION = _("Provides support for scanning LCSC barcodes")
VERSION = "1.0.0"
AUTHOR = _("InvenTree contributors")
DEFAULT_SUPPLIER_NAME = "LCSC"
SETTINGS = {
"SUPPLIER_ID": {
"name": _("Supplier"),
"description": _("The Supplier which acts as 'LCSC'"),
"model": "company.company",
}
}
def parse_supplier_barcode_data(self, barcode_data):
"""Get supplier_part and barcode_fields from LCSC QR-Code."""
if not isinstance(barcode_data, str):
return None
if not (match := LCSC_BARCODE_REGEX.fullmatch(barcode_data)):
return None
barcode_fields = dict(pair.split(":") for pair in match.group(1).split(","))
return SupplierBarcodeData(
SKU=barcode_fields.get("pc"),
MPN=barcode_fields.get("pm"),
quantity=barcode_fields.get("qty"),
order_number=barcode_fields.get("on"),
)
LCSC_BARCODE_REGEX = re.compile(r"^{((?:[^:,]+:[^:,]*,)*(?:[^:,]+:[^:,]*))}$")

View File

@ -0,0 +1,49 @@
"""The MouserPlugin is meant to integrate the Mouser API into Inventree.
This plugin currently only match Mouser barcodes to supplier parts.
"""
import logging
from django.utils.translation import gettext_lazy as _
from plugin import InvenTreePlugin
from plugin.base.barcodes.mixins import SupplierBarcodeData
from plugin.mixins import SettingsMixin, SupplierBarcodeMixin
logger = logging.getLogger('inventree')
class MouserPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin):
"""Plugin to integrate the Mouser API into Inventree."""
NAME = "MouserPlugin"
TITLE = _("Supplier Integration - Mouser")
DESCRIPTION = _("Provides support for scanning Mouser barcodes")
VERSION = "1.0.0"
AUTHOR = _("InvenTree contributors")
DEFAULT_SUPPLIER_NAME = "Mouser"
SETTINGS = {
"SUPPLIER_ID": {
"name": _("Supplier"),
"description": _("The Supplier which acts as 'Mouser'"),
"model": "company.company",
}
}
def parse_supplier_barcode_data(self, barcode_data):
"""Get supplier_part and barcode_fields from Mouser DataMatrix-Code."""
if not isinstance(barcode_data, str):
return None
if not (barcode_fields := self.parse_ecia_barcode2d(barcode_data)):
return None
return SupplierBarcodeData(
SKU=barcode_fields.get("supplier_part_number"),
MPN=barcode_fields.get("manufacturer_part_number"),
quantity=barcode_fields.get("quantity"),
order_number=barcode_fields.get("purchase_order_number"),
)

View File

@ -0,0 +1,308 @@
"""Tests barcode parsing for all suppliers."""
from django.urls import reverse
from company.models import Company, ManufacturerPart, SupplierPart
from InvenTree.unit_test import InvenTreeAPITestCase
from order.models import PurchaseOrder, PurchaseOrderLineItem
from part.models import Part
from stock.models import StockItem, StockLocation
class SupplierBarcodeTests(InvenTreeAPITestCase):
"""Tests barcode parsing for all suppliers."""
@classmethod
def setUpTestData(cls):
"""Create supplier parts for barcodes."""
super().setUpTestData()
part = Part.objects.create(name="Test Part", description="Test Part")
manufacturer = Company.objects.create(
name="Test Manufacturer", is_manufacturer=True)
mpart1 = ManufacturerPart.objects.create(
part=part, manufacturer=manufacturer, MPN="MC34063ADR")
mpart2 = ManufacturerPart.objects.create(
part=part, manufacturer=manufacturer, MPN="LDK320ADU33R")
supplier = Company.objects.create(name="Supplier", is_supplier=True)
mouser = Company.objects.create(name="Mouser Test", is_supplier=True)
supplier_parts = [
SupplierPart(SKU="296-LM358BIDDFRCT-ND", part=part, supplier=supplier),
SupplierPart(SKU="1", part=part, manufacturer_part=mpart1, supplier=mouser),
SupplierPart(SKU="2", part=part, manufacturer_part=mpart2, supplier=mouser),
SupplierPart(SKU="C312270", part=part, supplier=supplier),
SupplierPart(SKU="WBP-302", part=part, supplier=supplier),
]
SupplierPart.objects.bulk_create(supplier_parts)
def test_digikey_barcode(self):
"""Test digikey barcode."""
url = reverse("api-barcode-scan")
result = self.post(url, data={"barcode": DIGIKEY_BARCODE})
supplier_part_data = result.data.get("supplierpart")
assert "pk" in supplier_part_data
supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"])
assert supplier_part.SKU == "296-LM358BIDDFRCT-ND"
def test_mouser_barcode(self):
"""Test mouser barcode with custom order number."""
url = reverse("api-barcode-scan")
result = self.post(url, data={"barcode": MOUSER_BARCODE})
supplier_part_data = result.data.get("supplierpart")
assert "pk" in supplier_part_data
supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"])
assert supplier_part.SKU == "1"
def test_old_mouser_barcode(self):
"""Test old mouser barcode with messed up header."""
url = reverse("api-barcode-scan")
result = self.post(url, data={"barcode": MOUSER_BARCODE_OLD})
supplier_part_data = result.data.get("supplierpart")
assert "pk" in supplier_part_data
supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"])
assert supplier_part.SKU == "2"
def test_lcsc_barcode(self):
"""Test LCSC barcode."""
url = reverse("api-barcode-scan")
result = self.post(url, data={"barcode": LCSC_BARCODE})
supplier_part_data = result.data.get("supplierpart")
assert supplier_part_data is not None
assert "pk" in supplier_part_data
supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"])
assert supplier_part.SKU == "C312270"
def test_tme_qrcode(self):
"""Test TME QR-Code."""
url = reverse("api-barcode-scan")
result = self.post(url, data={"barcode": TME_QRCODE})
supplier_part_data = result.data.get("supplierpart")
assert supplier_part_data is not None
assert "pk" in supplier_part_data
supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"])
assert supplier_part.SKU == "WBP-302"
def test_tme_barcode2d(self):
"""Test TME DataMatrix-Code."""
url = reverse("api-barcode-scan")
result = self.post(url, data={"barcode": TME_DATAMATRIX_CODE})
supplier_part_data = result.data.get("supplierpart")
assert supplier_part_data is not None
assert "pk" in supplier_part_data
supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"])
assert supplier_part.SKU == "WBP-302"
class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
"""Tests barcode scanning to receive a purchase order item."""
def setUp(self):
"""Create supplier part and purchase_order."""
super().setUp()
part = Part.objects.create(name="Test Part", description="Test Part")
supplier = Company.objects.create(name="Supplier", is_supplier=True)
manufacturer = Company.objects.create(
name="Test Manufacturer", is_manufacturer=True)
mouser = Company.objects.create(name="Mouser Test", is_supplier=True)
mpart = ManufacturerPart.objects.create(
part=part, manufacturer=manufacturer, MPN="MC34063ADR")
self.purchase_order1 = PurchaseOrder.objects.create(
supplier_reference="72991337", supplier=supplier)
supplier_parts1 = [
SupplierPart(SKU=f"1_{i}", part=part, supplier=supplier)
for i in range(6)
]
supplier_parts1.insert(
2, SupplierPart(SKU="296-LM358BIDDFRCT-ND", part=part, supplier=supplier))
for supplier_part in supplier_parts1:
supplier_part.save()
self.purchase_order1.add_line_item(supplier_part, 8)
self.purchase_order2 = PurchaseOrder.objects.create(
reference="P0-1337", supplier=mouser)
self.purchase_order2.place_order()
supplier_parts2 = [
SupplierPart(SKU=f"2_{i}", part=part, supplier=mouser)
for i in range(6)
]
supplier_parts2.insert(
3, SupplierPart(SKU="42", part=part, manufacturer_part=mpart, supplier=mouser))
for supplier_part in supplier_parts2:
supplier_part.save()
self.purchase_order2.add_line_item(supplier_part, 5)
def test_receive(self):
"""Test receiving an item from a barcode."""
url = reverse("api-barcode-po-receive")
result1 = self.post(url, data={"barcode": DIGIKEY_BARCODE})
assert result1.status_code == 400
assert result1.data["error"].startswith("Failed to find placed purchase order")
self.purchase_order1.place_order()
result2 = self.post(url, data={"barcode": DIGIKEY_BARCODE})
assert result2.status_code == 200
assert "success" in result2.data
result3 = self.post(url, data={"barcode": DIGIKEY_BARCODE})
assert result3.status_code == 400
assert result3.data["error"].startswith(
"Item has already been received")
result4 = self.post(url, data={"barcode": DIGIKEY_BARCODE[:-1]})
assert result4.status_code == 400
assert result4.data["error"].startswith(
"Failed to find pending line item for supplier part")
result5 = self.post(reverse("api-barcode-scan"), data={"barcode": DIGIKEY_BARCODE})
assert result5.status_code == 200
stock_item = StockItem.objects.get(pk=result5.data["stockitem"]["pk"])
assert stock_item.supplier_part.SKU == "296-LM358BIDDFRCT-ND"
assert stock_item.quantity == 10
assert stock_item.location is None
def test_receive_custom_order_number(self):
"""Test receiving an item from a barcode with a custom order number."""
url = reverse("api-barcode-po-receive")
result1 = self.post(url, data={"barcode": MOUSER_BARCODE})
assert "success" in result1.data
result2 = self.post(reverse("api-barcode-scan"), data={"barcode": MOUSER_BARCODE})
stock_item = StockItem.objects.get(pk=result2.data["stockitem"]["pk"])
assert stock_item.supplier_part.SKU == "42"
assert stock_item.supplier_part.manufacturer_part.MPN == "MC34063ADR"
assert stock_item.quantity == 3
assert stock_item.location is None
def test_receive_one_stock_location(self):
"""Test receiving an item when only one stock location exists"""
stock_location = StockLocation.objects.create(name="Test Location")
url = reverse("api-barcode-po-receive")
result1 = self.post(url, data={"barcode": MOUSER_BARCODE})
assert "success" in result1.data
result2 = self.post(reverse("api-barcode-scan"), data={"barcode": MOUSER_BARCODE})
stock_item = StockItem.objects.get(pk=result2.data["stockitem"]["pk"])
assert stock_item.location == stock_location
def test_receive_default_line_item_location(self):
"""Test receiving an item into the default line_item location"""
StockLocation.objects.create(name="Test Location 1")
stock_location2 = StockLocation.objects.create(name="Test Location 2")
line_item = PurchaseOrderLineItem.objects.filter(part__SKU="42")[0]
line_item.destination = stock_location2
line_item.save()
url = reverse("api-barcode-po-receive")
result1 = self.post(url, data={"barcode": MOUSER_BARCODE})
assert "success" in result1.data
result2 = self.post(reverse("api-barcode-scan"), data={"barcode": MOUSER_BARCODE})
stock_item = StockItem.objects.get(pk=result2.data["stockitem"]["pk"])
assert stock_item.location == stock_location2
def test_receive_default_part_location(self):
"""Test receiving an item into the default part location"""
StockLocation.objects.create(name="Test Location 1")
stock_location2 = StockLocation.objects.create(name="Test Location 2")
part = Part.objects.all()[0]
part.default_location = stock_location2
part.save()
url = reverse("api-barcode-po-receive")
result1 = self.post(url, data={"barcode": MOUSER_BARCODE})
assert "success" in result1.data
result2 = self.post(reverse("api-barcode-scan"), data={"barcode": MOUSER_BARCODE})
stock_item = StockItem.objects.get(pk=result2.data["stockitem"]["pk"])
assert stock_item.location == stock_location2
def test_receive_specific_order_and_location(self):
"""Test receiving an item from a specific order into a specific location"""
StockLocation.objects.create(name="Test Location 1")
stock_location2 = StockLocation.objects.create(name="Test Location 2")
url = reverse("api-barcode-po-receive")
barcode = MOUSER_BARCODE.replace("\x1dKP0-1337", "")
result1 = self.post(url, data={
"barcode": barcode,
"purchase_order": self.purchase_order2.pk,
"location": stock_location2.pk,
})
assert "success" in result1.data
result2 = self.post(reverse("api-barcode-scan"), data={"barcode": barcode})
stock_item = StockItem.objects.get(pk=result2.data["stockitem"]["pk"])
assert stock_item.location == stock_location2
def test_receive_missing_quantity(self):
"""Test receiving an with missing quantity information"""
url = reverse("api-barcode-po-receive")
barcode = MOUSER_BARCODE.replace("\x1dQ3", "")
result = self.post(url, data={"barcode": barcode})
assert "lineitem" in result.data
assert "quantity" not in result.data["lineitem"]
DIGIKEY_BARCODE = (
"[)>\x1e06\x1dP296-LM358BIDDFRCT-ND\x1d1PLM358BIDDFR\x1dK\x1d1K72991337\x1d"
"10K85781337\x1d11K1\x1d4LPH\x1dQ10\x1d11ZPICK\x1d12Z15221337\x1d13Z361337"
"\x1d20Z0000000000000000000000000000000000000000000000000000000000000000000"
"00000000000000000000000000000000000000000000000000000000000000000000000000"
"0000000000000000000000000000000000"
)
MOUSER_BARCODE = (
"[)>\x1e06\x1dKP0-1337\x1d14K011\x1d1PMC34063ADR\x1dQ3\x1d11K073121337\x1d4"
"LMX\x1d1VTI\x1e\x04"
)
MOUSER_BARCODE_OLD = (
">[)>06\x1dK21421337\x1d14K033\x1d1PLDK320ADU33R\x1dQ32\x1d11K060931337\x1d"
"4LCN\x1d1VSTMicro"
)
LCSC_BARCODE = (
"{pbn:PICK2009291337,on:SO2009291337,pc:C312270,pm:ST-1-102-A01-T000-RS,qty"
":2,mc:,cc:1,pdi:34421807}"
)
TME_QRCODE = (
"QTY:1 PN:WBP-302 PO:19361337/1 CPO:PO-2023-06-08-001337 MFR:WISHERENTERPRI"
"SE MPN:WBP-302 RoHS https://www.tme.eu/details/WBP-302"
)
TME_DATAMATRIX_CODE = "PWBP-302 1PMPNWBP-302 Q1 K19361337/1"

View File

@ -0,0 +1,74 @@
"""The TMEPlugin is meant to integrate the TME API into Inventree.
This plugin can currently only match TME barcodes to supplier parts.
"""
import logging
import re
from django.utils.translation import gettext_lazy as _
from plugin import InvenTreePlugin
from plugin.base.barcodes.mixins import SupplierBarcodeData
from plugin.mixins import SettingsMixin, SupplierBarcodeMixin
logger = logging.getLogger('inventree')
class TMEPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin):
"""Plugin to integrate the TME API into Inventree."""
NAME = "TMEPlugin"
TITLE = _("Supplier Integration - TME")
DESCRIPTION = _("Provides support for scanning TME barcodes")
VERSION = "1.0.0"
AUTHOR = _("InvenTree contributors")
DEFAULT_SUPPLIER_NAME = "TME"
SETTINGS = {
"SUPPLIER_ID": {
"name": _("Supplier"),
"description": _("The Supplier which acts as 'TME'"),
"model": "company.company",
}
}
def parse_supplier_barcode_data(self, barcode_data):
"""Get supplier_part and barcode_fields from TME QR-Code or DataMatrix-Code."""
if not isinstance(barcode_data, str):
return None
if TME_IS_QRCODE_REGEX.fullmatch(barcode_data):
barcode_fields = {
QRCODE_FIELD_NAME_MAP.get(field_name, field_name): value
for field_name, value in TME_PARSE_QRCODE_REGEX.findall(barcode_data)
}
elif TME_IS_BARCODE2D_REGEX.fullmatch(barcode_data):
barcode_fields = self.parse_ecia_barcode2d(
TME_PARSE_BARCODE2D_REGEX.findall(barcode_data)
)
else:
return None
if order_number := barcode_fields.get("purchase_order_number"):
order_number = order_number.split("/")[0]
return SupplierBarcodeData(
SKU=barcode_fields.get("supplier_part_number"),
MPN=barcode_fields.get("manufacturer_part_number"),
quantity=barcode_fields.get("quantity"),
order_number=order_number,
)
TME_IS_QRCODE_REGEX = re.compile(r"([^\s:]+:[^\s:]+\s+)+(\S+(\s|$)+)+")
TME_PARSE_QRCODE_REGEX = re.compile(r"([^\s:]+):([^\s:]+)(?:\s+|$)")
TME_IS_BARCODE2D_REGEX = re.compile(r"(([^\s]+)(\s+|$))+")
TME_PARSE_BARCODE2D_REGEX = re.compile(r"([^\s]+)(?:\s+|$)")
QRCODE_FIELD_NAME_MAP = {
"PN": "supplier_part_number",
"PO": "purchase_order_number",
"MPN": "manufacturer_part_number",
"QTY": "quantity",
}

View File

@ -3,7 +3,7 @@
from common.notifications import (BulkNotificationMethod,
SingleNotificationMethod)
from plugin.base.action.mixins import ActionMixin
from plugin.base.barcodes.mixins import BarcodeMixin
from plugin.base.barcodes.mixins import BarcodeMixin, SupplierBarcodeMixin
from plugin.base.event.mixins import EventMixin
from plugin.base.integration.APICallMixin import APICallMixin
from plugin.base.integration.AppMixin import AppMixin
@ -33,6 +33,7 @@ __all__ = [
'PanelMixin',
'ActionMixin',
'BarcodeMixin',
'SupplierBarcodeMixin',
'LocateMixin',
'ValidationMixin',
'SingleNotificationMethod',

View File

@ -26,3 +26,16 @@ The barcode is tested as follows, in decreasing order of priority:
!!! tip "Plugin Loading Order"
The first custom plugin to return a result "wins". As the loading order of custom plugins is not defined (or configurable), take special care if you are running multiple plugins which support barcode actions.
## Builtin Supplier Barcode Plugins
InvenTree comes with a few builtin supplier plugins, which handle their respective barcode formats.
Scanning a supplier barcode for a supplied part will link to the corresponding supplier part if the [SKU](../report/context_variables.md#supplierpart) from the barcode could be matched.
The following suppliers (and barcode formats) are currently supported:
- DigiKey (2D Data Matrix code)
- Mouser (2D Data Matrix code)
- LCSC (QR code)
- TME (QR code & 2D Data Matrix code)

View File

@ -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({
</Alert>
)}
{preFormElement}
<ScrollArea>
<Stack spacing="xs">
{Object.entries(props.fields ?? {}).map(
([fieldName, field]) =>
!field.hidden && (
<ApiFormField
key={fieldName}
field={field}
fieldName={fieldName}
formProps={props}
form={form}
error={form.errors[fieldName] ?? null}
definitions={fieldDefinitions}
/>
)
)}
</Stack>
</ScrollArea>
<Stack spacing="xs">
{Object.entries(props.fields ?? {}).map(
([fieldName, field]) =>
!field.hidden && (
<ApiFormField
key={fieldName}
field={field}
fieldName={fieldName}
formProps={props}
form={form}
error={form.errors[fieldName] ?? null}
definitions={fieldDefinitions}
/>
)
)}
</Stack>
{postFormElement}
</Stack>
<Divider />

View File

@ -19,7 +19,7 @@ export function Thumbnail({
return (
<ApiImage
src={src}
src={src || '/static/img/blank_image.png'}
alt={alt}
width={size}
fit="contain"

View File

@ -0,0 +1,65 @@
import { ActionIcon, Menu, Tooltip } from '@mantine/core';
import { ReactNode, useMemo } from 'react';
import { notYetImplemented } from '../../functions/notifications';
export type ActionDropdownItem = {
icon: ReactNode;
name: string;
tooltip?: string;
disabled?: boolean;
onClick?: () => 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 ? (
<Menu position="bottom-end">
<Menu.Target>
<Tooltip label={tooltip}>
<ActionIcon size="lg" radius="sm" variant="outline">
{icon}
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
{actions.map((action, index) =>
action.disabled ? null : (
<Tooltip label={action.tooltip}>
<Menu.Item
icon={action.icon}
key={index}
onClick={() => {
if (action.onClick != undefined) {
action.onClick();
} else {
notYetImplemented();
}
}}
disabled={action.disabled}
>
{action.name}
</Menu.Item>
</Tooltip>
)
)}
</Menu.Dropdown>
</Menu>
) : null;
}

View File

@ -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 (
<ProtectedRoute>
<Flex direction="column" mih="100vh">
<LoadingOverlay visible={modalState.loading} />
<Header />
<Container className={classes.layoutContent} size="100%">
<Outlet />

View File

@ -41,7 +41,11 @@ export function PageDetail({
</Stack>
</Group>
<Space />
{actions && <Group position="right">{actions}</Group>}
{actions && (
<Group spacing={5} position="right">
{actions}
</Group>
)}
</Group>
</Stack>
</Paper>

View File

@ -224,20 +224,22 @@ export function AttachmentTable({
return (
<Stack spacing="xs">
<InvenTreeTable
url={url}
tableKey={tableKey}
columns={tableColumns}
props={{
noRecordsText: t`No attachments found`,
enableSelection: true,
customActionGroups: customActionGroups,
rowActions: allowEdit && allowDelete ? rowActions : undefined,
params: {
[model]: pk
}
}}
/>
{pk && pk > 0 && (
<InvenTreeTable
url={url}
tableKey={tableKey}
columns={tableColumns}
props={{
noRecordsText: t`No attachments found`,
enableSelection: true,
customActionGroups: customActionGroups,
rowActions: allowEdit && allowDelete ? rowActions : undefined,
params: {
[model]: pk
}
}}
/>
)}
{allowEdit && validPk && (
<Dropzone onDrop={uploadFiles}>
<Dropzone.Idle>

View File

@ -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 (
<Group spacing="xs" noWrap={true}>
<Thumbnail
src={record.thumbnail ?? record.image}
src={record.thumbnail ?? record.image ?? ''}
alt={record.name}
size={24}
/>
@ -56,6 +65,10 @@ export function CompanyTable({ params }: { params?: any }) {
props={{
params: {
...params
},
onRowClick: (row: any) => {
let base = path ?? 'company';
navigate(`/${base}/${row.pk}`);
}
}}
/>

View File

@ -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) {
<ApiForm modalId={modalId} props={props} fieldDefinitions={fields} />
)
});
useModalState.getState().unlock();
})
.catch((error) => {
useModalState.getState().unlock();
console.log('Error:', error);
if (error.response) {
invalidResponse(error.response.status);

View File

@ -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: <IconGlobe />
},
currency: {
icon: <IconCurrencyDollar />
},
phone: {
icon: <IconPhone />
},
email: {
icon: <IconAt />
},
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
});
}

View File

@ -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 [
<ActionDropdown
tooltip={t`Barcode Actions`}
icon={<IconQrcode />}
actions={[
{
icon: <IconQrcode />,
name: t`View`,
tooltip: t`View part barcode`
},
{
icon: <IconLink />,
name: t`Link Barcode`,
tooltip: t`Link custom barcode to part`,
disabled: build?.barcode_hash
},
{
icon: <IconUnlink />,
name: t`Unlink Barcode`,
tooltip: t`Unlink custom barcode from part`,
disabled: !build?.barcode_hash
}
]}
/>,
<ActionDropdown
tooltip={t`Reporting Actions`}
icon={<IconPrinter />}
actions={[
{
icon: <IconFileTypePdf />,
name: t`Report`,
tooltip: t`Print build report`
}
]}
/>,
<ActionDropdown
tooltip={t`Build Order Actions`}
icon={<IconDots />}
actions={[
{
icon: <IconEdit color="blue" />,
name: t`Edit`,
tooltip: t`Edit build order`
},
{
icon: <IconCopy color="green" />,
name: t`Duplicate`,
tooltip: t`Duplicate build order`
},
{
icon: <IconTrash color="red" />,
name: t`Delete`,
tooltip: t`Delete build order`
}
]}
/>
];
}, [id, build, user]);
return (
<>
<Stack spacing="xs">
<PageDetail
title={t`Build Order`}
subtitle={build.reference}
detail={
<Alert color="teal" title="Build order detail goes here">
<Text>TODO: Build details</Text>
</Alert>
}
breadcrumbs={[
{ name: t`Build Orders`, url: '/build' },
{ name: build.reference, url: `/build/${build.pk}` }
]}
actions={[<PlaceholderPill key="1" />]}
actions={buildActions}
/>
<LoadingOverlay visible={instanceQuery.isFetching} />
<PanelGroup pageKey="build" panels={buildPanels} />

View File

@ -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: <IconInfoCircle />
},
{
name: 'manufactured-parts',
label: t`Manufactured Parts`,
icon: <IconBuildingFactory2 />,
hidden: !company?.is_manufacturer
},
{
name: 'supplied-parts',
label: t`Supplied Parts`,
icon: <IconBuildingWarehouse />,
hidden: !company?.is_supplier
},
{
name: 'purchase-orders',
label: t`Purchase Orders`,
icon: <IconShoppingCart />,
hidden: !company?.is_supplier,
content: company?.pk && (
<PurchaseOrderTable params={{ supplier: company.pk }} />
)
},
{
name: 'stock-items',
label: t`Stock Items`,
icon: <IconPackages />,
hidden: !company?.is_manufacturer && !company?.is_supplier,
content: company?.pk && (
<StockItemTable params={{ company: company.pk }} />
)
},
{
name: 'sales-orders',
label: t`Sales Orders`,
icon: <IconTruckDelivery />,
hidden: !company?.is_customer,
content: company?.pk && (
<SalesOrderTable params={{ customer: company.pk }} />
)
},
{
name: 'return-orders',
label: t`Return Orders`,
icon: <IconTruckReturn />,
hidden: !company?.is_customer,
content: company.pk && (
<ReturnOrderTable params={{ customer: company.pk }} />
)
},
{
name: 'assigned-stock',
label: t`Assigned Stock`,
icon: <IconPackageExport />,
hidden: !company?.is_customer
},
{
name: 'contacts',
label: t`Contacts`,
icon: <IconUsersGroup />
},
{
name: 'addresses',
label: t`Addresses`,
icon: <IconMap2 />
},
{
name: 'attachments',
label: t`Attachments`,
icon: <IconPaperclip />,
content: (
<AttachmentTable
endpoint={ApiPaths.company_attachment_list}
model="company"
pk={company.pk ?? -1}
/>
)
},
{
name: 'notes',
label: t`Notes`,
icon: <IconNotes />,
content: (
<NotesEditor
url={apiUrl(ApiPaths.company_list, company.pk)}
data={company?.notes ?? ''}
allowEdit={true}
/>
)
}
];
}, [id, company]);
const companyDetail = useMemo(() => {
return (
<Group spacing="xs" noWrap={true}>
<Thumbnail
src={String(company.image || '')}
size={128}
alt={company?.name}
/>
<Stack spacing="xs">
<Text size="lg" weight={500}>
{company.name}
</Text>
<Text size="sm">{company.description}</Text>
</Stack>
</Group>
);
}, [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 [
<ActionDropdown
tooltip={t`Company Actions`}
icon={<IconDots />}
actions={[
{
icon: <IconEdit color="blue" />,
name: t`Edit`,
tooltip: t`Edit company`,
disabled: !canEdit,
onClick: () => {
if (company?.pk) {
editCompany({
pk: company?.pk,
callback: refreshInstance
});
}
}
},
{
icon: <IconTrash color="red" />,
name: t`Delete`,
tooltip: t`Delete company`,
disabled: !canDelete
}
]}
/>
];
}, [id, company, user]);
return (
<Stack spacing="xs">
<LoadingOverlay visible={instanceQuery.isFetching} />
<PageDetail
detail={companyDetail}
actions={companyActions}
breadcrumbs={props.breadcrumbs}
/>
<PanelGroup pageKey="company" panels={companyPanels} />
</Stack>
);
}

View File

@ -0,0 +1,12 @@
import { t } from '@lingui/macro';
import CompanyDetail from './CompanyDetail';
export default function CustomerDetail() {
return (
<CompanyDetail
title={t`Customer`}
breadcrumbs={[{ name: t`Sales`, url: '/sales/' }]}
/>
);
}

View File

@ -0,0 +1,12 @@
import { t } from '@lingui/macro';
import CompanyDetail from './CompanyDetail';
export default function ManufacturerDetail() {
return (
<CompanyDetail
title={t`Manufacturer`}
breadcrumbs={[{ name: t`Purchasing`, url: '/purchasing/' }]}
/>
);
}

View File

@ -0,0 +1,12 @@
import { t } from '@lingui/macro';
import CompanyDetail from './CompanyDetail';
export default function SupplierDetail() {
return (
<CompanyDetail
title={t`Supplier`}
breadcrumbs={[{ name: t`Purchasing`, url: '/purchasing/' }]}
/>
);
}

View File

@ -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: <IconInfoCircle />,
content: <PlaceholderPanel />
icon: <IconInfoCircle />
},
{
name: 'parameters',
@ -98,55 +103,57 @@ export default function PartDetail() {
name: 'bom',
label: t`Bill of Materials`,
icon: <IconListTree />,
hidden: !part.assembly,
content: <PlaceholderPanel />
hidden: !part.assembly
},
{
name: 'builds',
label: t`Build Orders`,
icon: <IconTools />,
hidden: !part.assembly && !part.component,
content: <PlaceholderPanel />
hidden: !part.assembly && !part.component
},
{
name: 'used_in',
label: t`Used In`,
icon: <IconStack2 />,
hidden: !part.component,
content: <PlaceholderPanel />
hidden: !part.component
},
{
name: 'pricing',
label: t`Pricing`,
icon: <IconCurrencyDollar />,
content: <PlaceholderPanel />
icon: <IconCurrencyDollar />
},
{
name: 'suppliers',
label: t`Suppliers`,
icon: <IconBuilding />,
hidden: !part.purchaseable,
content: <PlaceholderPanel />
hidden: !part.purchaseable
},
{
name: 'purchase_orders',
label: t`Purchase Orders`,
icon: <IconShoppingCart />,
content: <PlaceholderPanel />,
hidden: !part.purchaseable
},
{
name: 'sales_orders',
label: t`Sales Orders`,
icon: <IconTruckDelivery />,
content: <PlaceholderPanel />,
hidden: !part.salable
},
{
name: 'scheduling',
label: t`Scheduling`,
icon: <IconCalendarStats />
},
{
name: 'stocktake',
label: t`Stocktake`,
icon: <IconClipboardList />
},
{
name: 'test_templates',
label: t`Test Templates`,
icon: <IconTestPipe />,
content: <PlaceholderPanel />,
hidden: !part.trackable
},
{
@ -212,6 +219,79 @@ export default function PartDetail() {
);
}, [part, id]);
const partActions = useMemo(() => {
// TODO: Disable actions based on user permissions
return [
<ActionDropdown
tooltip={t`Barcode Actions`}
icon={<IconQrcode />}
actions={[
{
icon: <IconQrcode />,
name: t`View`,
tooltip: t`View part barcode`
},
{
icon: <IconLink />,
name: t`Link Barcode`,
tooltip: t`Link custom barcode to part`,
disabled: part?.barcode_hash
},
{
icon: <IconUnlink />,
name: t`Unlink Barcode`,
tooltip: t`Unlink custom barcode from part`,
disabled: !part?.barcode_hash
}
]}
/>,
<ActionDropdown
tooltip={t`Stock Actions`}
icon={<IconPackages />}
actions={[
{
icon: <IconClipboardList color="blue" />,
name: t`Count Stock`,
tooltip: t`Count part stock`
},
{
icon: <IconTransfer color="blue" />,
name: t`Transfer Stock`,
tooltip: t`Transfer part stock`
}
]}
/>,
<ActionDropdown
tooltip={t`Part Actions`}
icon={<IconDots />}
actions={[
{
icon: <IconEdit color="blue" />,
name: t`Edit`,
tooltip: t`Edit part`,
onClick: () => {
part.pk &&
editPart({
part_id: part.pk,
callback: refreshInstance
});
}
},
{
icon: <IconCopy color="green" />,
name: t`Duplicate`,
tooltip: t`Duplicate part`
},
{
icon: <IconTrash color="red" />,
name: t`Delete`,
tooltip: t`Delete part`
}
]}
/>
];
}, [id, part, user]);
return (
<>
<Stack spacing="xs">
@ -219,21 +299,7 @@ export default function PartDetail() {
<PageDetail
detail={partDetail}
breadcrumbs={breadcrumbs}
actions={[
<Button
variant="outline"
color="blue"
onClick={() =>
part.pk &&
editPart({
part_id: part.pk,
callback: refreshInstance
})
}
>
Edit Part
</Button>
]}
actions={partActions}
/>
<PanelGroup pageKey="part" panels={partPanels} />
</Stack>

View File

@ -26,13 +26,23 @@ export default function PurchasingIndex() {
name: 'suppliers',
label: t`Suppliers`,
icon: <IconBuildingStore />,
content: <CompanyTable params={{ is_supplier: true }} />
content: (
<CompanyTable
path="purchasing/supplier"
params={{ is_supplier: true }}
/>
)
},
{
name: 'manufacturer',
label: t`Manufacturers`,
icon: <IconBuildingFactory2 />,
content: <CompanyTable params={{ is_manufacturer: true }} />
content: (
<CompanyTable
path="purchasing/manufacturer"
params={{ is_manufacturer: true }}
/>
)
}
];
}, []);

View File

@ -32,7 +32,9 @@ export default function PurchasingIndex() {
name: 'suppliers',
label: t`Customers`,
icon: <IconBuildingStore />,
content: <CompanyTable params={{ is_customer: true }} />
content: (
<CompanyTable path="sales/customer" params={{ is_customer: true }} />
)
}
];
}, []);

View File

@ -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 = (
</Route>
<Route path="purchasing/">
<Route index element={<PurchasingIndex />} />
<Route path="supplier/:id/" element={<SupplierDetail />} />
<Route path="manufacturer/:id/" element={<ManufacturerDetail />} />
</Route>
<Route path="company/:id/" element={<CompanyDetail />} />
<Route path="sales/">
<Route index element={<SalesIndex />} />
<Route path="customer/:id/" element={<CustomerDetail />} />
</Route>
<Route path="/profile/:tabValue" element={<Profile />} />
</Route>

View File

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

View File

@ -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<ModalStateProps>((set) => ({
loading: false,
lock: () => set(() => ({ loading: true })),
unlock: () => set(() => ({ loading: false }))
}));

View File

@ -10,6 +10,7 @@ interface UserStateProps {
username: () => string;
setUser: (newUser: UserProps) => void;
fetchUserState: () => void;
checkUserRole: (role: string, permission: string) => boolean;
}
/**