mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-15 03:25:42 +00:00
Merge branch 'inventree:master' into matmair/issue5729
This commit is contained in:
@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
|
|
||||||
# InvenTree API version
|
# 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
|
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
|
v138 -> 2023-10-11 : https://github.com/inventree/InvenTree/pull/5679
|
||||||
- Settings keys are no longer case sensitive
|
- Settings keys are no longer case sensitive
|
||||||
- Include settings units in API serializer
|
- Include settings units in API serializer
|
||||||
|
@ -10,9 +10,11 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from InvenTree.helpers import hash_barcode
|
from InvenTree.helpers import hash_barcode
|
||||||
|
from order.models import PurchaseOrder
|
||||||
from plugin import registry
|
from plugin import registry
|
||||||
from plugin.builtin.barcodes.inventree_barcode import \
|
from plugin.builtin.barcodes.inventree_barcode import \
|
||||||
InvenTreeInternalBarcodePlugin
|
InvenTreeInternalBarcodePlugin
|
||||||
|
from stock.models import StockLocation
|
||||||
from users.models import RuleSet
|
from users.models import RuleSet
|
||||||
|
|
||||||
|
|
||||||
@ -230,7 +232,7 @@ class BarcodeUnassign(APIView):
|
|||||||
instance.unassign_barcode()
|
instance.unassign_barcode()
|
||||||
|
|
||||||
return Response({
|
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!
|
# 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 = [
|
barcode_api_urls = [
|
||||||
# Link a third-party barcode to an item (e.g. Part / StockItem / etc)
|
# Link a third-party barcode to an item (e.g. Part / StockItem / etc)
|
||||||
path('link/', BarcodeAssign.as_view(), name='api-barcode-link'),
|
path('link/', BarcodeAssign.as_view(), name='api-barcode-link'),
|
||||||
@ -246,6 +326,9 @@ barcode_api_urls = [
|
|||||||
# Unlink a third-pary barcode from an item
|
# Unlink a third-pary barcode from an item
|
||||||
path('unlink/', BarcodeUnassign.as_view(), name='api-barcode-unlink'),
|
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'
|
# Catch-all performs barcode 'scan'
|
||||||
re_path(r'^.*$', BarcodeScan.as_view(), name='api-barcode-scan'),
|
re_path(r'^.*$', BarcodeScan.as_view(), name='api-barcode-scan'),
|
||||||
]
|
]
|
||||||
|
@ -1,5 +1,22 @@
|
|||||||
"""Plugin mixin classes for barcode plugin."""
|
"""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:
|
class BarcodeMixin:
|
||||||
"""Mixin that enables barcode handling.
|
"""Mixin that enables barcode handling.
|
||||||
@ -36,3 +53,334 @@ class BarcodeMixin:
|
|||||||
Default return value is None
|
Default return value is None
|
||||||
"""
|
"""
|
||||||
return 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
|
||||||
|
}
|
||||||
|
0
InvenTree/plugin/builtin/suppliers/__init__.py
Normal file
0
InvenTree/plugin/builtin/suppliers/__init__.py
Normal file
49
InvenTree/plugin/builtin/suppliers/digikey.py
Normal file
49
InvenTree/plugin/builtin/suppliers/digikey.py
Normal 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"),
|
||||||
|
)
|
55
InvenTree/plugin/builtin/suppliers/lcsc.py
Normal file
55
InvenTree/plugin/builtin/suppliers/lcsc.py
Normal 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"^{((?:[^:,]+:[^:,]*,)*(?:[^:,]+:[^:,]*))}$")
|
49
InvenTree/plugin/builtin/suppliers/mouser.py
Normal file
49
InvenTree/plugin/builtin/suppliers/mouser.py
Normal 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"),
|
||||||
|
)
|
308
InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py
Normal file
308
InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py
Normal 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"
|
74
InvenTree/plugin/builtin/suppliers/tme.py
Normal file
74
InvenTree/plugin/builtin/suppliers/tme.py
Normal 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",
|
||||||
|
}
|
@ -3,7 +3,7 @@
|
|||||||
from common.notifications import (BulkNotificationMethod,
|
from common.notifications import (BulkNotificationMethod,
|
||||||
SingleNotificationMethod)
|
SingleNotificationMethod)
|
||||||
from plugin.base.action.mixins import ActionMixin
|
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.event.mixins import EventMixin
|
||||||
from plugin.base.integration.APICallMixin import APICallMixin
|
from plugin.base.integration.APICallMixin import APICallMixin
|
||||||
from plugin.base.integration.AppMixin import AppMixin
|
from plugin.base.integration.AppMixin import AppMixin
|
||||||
@ -33,6 +33,7 @@ __all__ = [
|
|||||||
'PanelMixin',
|
'PanelMixin',
|
||||||
'ActionMixin',
|
'ActionMixin',
|
||||||
'BarcodeMixin',
|
'BarcodeMixin',
|
||||||
|
'SupplierBarcodeMixin',
|
||||||
'LocateMixin',
|
'LocateMixin',
|
||||||
'ValidationMixin',
|
'ValidationMixin',
|
||||||
'SingleNotificationMethod',
|
'SingleNotificationMethod',
|
||||||
|
@ -26,3 +26,16 @@ The barcode is tested as follows, in decreasing order of priority:
|
|||||||
|
|
||||||
!!! tip "Plugin Loading Order"
|
!!! 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.
|
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)
|
||||||
|
@ -1,11 +1,5 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import {
|
import { Alert, Divider, LoadingOverlay, Text } from '@mantine/core';
|
||||||
Alert,
|
|
||||||
Divider,
|
|
||||||
LoadingOverlay,
|
|
||||||
ScrollArea,
|
|
||||||
Text
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { Button, Group, Stack } from '@mantine/core';
|
import { Button, Group, Stack } from '@mantine/core';
|
||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import { modals } from '@mantine/modals';
|
import { modals } from '@mantine/modals';
|
||||||
@ -277,24 +271,22 @@ export function ApiForm({
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{preFormElement}
|
{preFormElement}
|
||||||
<ScrollArea>
|
<Stack spacing="xs">
|
||||||
<Stack spacing="xs">
|
{Object.entries(props.fields ?? {}).map(
|
||||||
{Object.entries(props.fields ?? {}).map(
|
([fieldName, field]) =>
|
||||||
([fieldName, field]) =>
|
!field.hidden && (
|
||||||
!field.hidden && (
|
<ApiFormField
|
||||||
<ApiFormField
|
key={fieldName}
|
||||||
key={fieldName}
|
field={field}
|
||||||
field={field}
|
fieldName={fieldName}
|
||||||
fieldName={fieldName}
|
formProps={props}
|
||||||
formProps={props}
|
form={form}
|
||||||
form={form}
|
error={form.errors[fieldName] ?? null}
|
||||||
error={form.errors[fieldName] ?? null}
|
definitions={fieldDefinitions}
|
||||||
definitions={fieldDefinitions}
|
/>
|
||||||
/>
|
)
|
||||||
)
|
)}
|
||||||
)}
|
</Stack>
|
||||||
</Stack>
|
|
||||||
</ScrollArea>
|
|
||||||
{postFormElement}
|
{postFormElement}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
@ -19,7 +19,7 @@ export function Thumbnail({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ApiImage
|
<ApiImage
|
||||||
src={src}
|
src={src || '/static/img/blank_image.png'}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
width={size}
|
width={size}
|
||||||
fit="contain"
|
fit="contain"
|
||||||
|
65
src/frontend/src/components/items/ActionDropdown.tsx
Normal file
65
src/frontend/src/components/items/ActionDropdown.tsx
Normal 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;
|
||||||
|
}
|
@ -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 { Navigate, Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
import { InvenTreeStyle } from '../../globalStyle';
|
import { InvenTreeStyle } from '../../globalStyle';
|
||||||
|
import { useModalState } from '../../states/ModalState';
|
||||||
import { useSessionState } from '../../states/SessionState';
|
import { useSessionState } from '../../states/SessionState';
|
||||||
import { Footer } from './Footer';
|
import { Footer } from './Footer';
|
||||||
import { Header } from './Header';
|
import { Header } from './Header';
|
||||||
@ -19,9 +20,12 @@ export const ProtectedRoute = ({ children }: { children: JSX.Element }) => {
|
|||||||
export default function LayoutComponent() {
|
export default function LayoutComponent() {
|
||||||
const { classes } = InvenTreeStyle();
|
const { classes } = InvenTreeStyle();
|
||||||
|
|
||||||
|
const modalState = useModalState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Flex direction="column" mih="100vh">
|
<Flex direction="column" mih="100vh">
|
||||||
|
<LoadingOverlay visible={modalState.loading} />
|
||||||
<Header />
|
<Header />
|
||||||
<Container className={classes.layoutContent} size="100%">
|
<Container className={classes.layoutContent} size="100%">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
@ -41,7 +41,11 @@ export function PageDetail({
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
<Space />
|
<Space />
|
||||||
{actions && <Group position="right">{actions}</Group>}
|
{actions && (
|
||||||
|
<Group spacing={5} position="right">
|
||||||
|
{actions}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
@ -224,20 +224,22 @@ export function AttachmentTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing="xs">
|
<Stack spacing="xs">
|
||||||
<InvenTreeTable
|
{pk && pk > 0 && (
|
||||||
url={url}
|
<InvenTreeTable
|
||||||
tableKey={tableKey}
|
url={url}
|
||||||
columns={tableColumns}
|
tableKey={tableKey}
|
||||||
props={{
|
columns={tableColumns}
|
||||||
noRecordsText: t`No attachments found`,
|
props={{
|
||||||
enableSelection: true,
|
noRecordsText: t`No attachments found`,
|
||||||
customActionGroups: customActionGroups,
|
enableSelection: true,
|
||||||
rowActions: allowEdit && allowDelete ? rowActions : undefined,
|
customActionGroups: customActionGroups,
|
||||||
params: {
|
rowActions: allowEdit && allowDelete ? rowActions : undefined,
|
||||||
[model]: pk
|
params: {
|
||||||
}
|
[model]: pk
|
||||||
}}
|
}
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{allowEdit && validPk && (
|
{allowEdit && validPk && (
|
||||||
<Dropzone onDrop={uploadFiles}>
|
<Dropzone onDrop={uploadFiles}>
|
||||||
<Dropzone.Idle>
|
<Dropzone.Idle>
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Group, Text } from '@mantine/core';
|
import { Group, Text } from '@mantine/core';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { useTableRefresh } from '../../../hooks/TableRefresh';
|
import { useTableRefresh } from '../../../hooks/TableRefresh';
|
||||||
import { ApiPaths, apiUrl } from '../../../states/ApiState';
|
import { ApiPaths, apiUrl } from '../../../states/ApiState';
|
||||||
@ -11,9 +12,17 @@ import { InvenTreeTable } from '../InvenTreeTable';
|
|||||||
* A table which displays a list of company records,
|
* A table which displays a list of company records,
|
||||||
* based on the provided filter parameters
|
* 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 { tableKey } = useTableRefresh('company');
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -24,7 +33,7 @@ export function CompanyTable({ params }: { params?: any }) {
|
|||||||
return (
|
return (
|
||||||
<Group spacing="xs" noWrap={true}>
|
<Group spacing="xs" noWrap={true}>
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
src={record.thumbnail ?? record.image}
|
src={record.thumbnail ?? record.image ?? ''}
|
||||||
alt={record.name}
|
alt={record.name}
|
||||||
size={24}
|
size={24}
|
||||||
/>
|
/>
|
||||||
@ -56,6 +65,10 @@ export function CompanyTable({ params }: { params?: any }) {
|
|||||||
props={{
|
props={{
|
||||||
params: {
|
params: {
|
||||||
...params
|
...params
|
||||||
|
},
|
||||||
|
onRowClick: (row: any) => {
|
||||||
|
let base = path ?? 'company';
|
||||||
|
navigate(`/${base}/${row.pk}`);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -7,6 +7,7 @@ import { api } from '../App';
|
|||||||
import { ApiForm, ApiFormProps } from '../components/forms/ApiForm';
|
import { ApiForm, ApiFormProps } from '../components/forms/ApiForm';
|
||||||
import { ApiFormFieldType } from '../components/forms/fields/ApiFormField';
|
import { ApiFormFieldType } from '../components/forms/fields/ApiFormField';
|
||||||
import { apiUrl } from '../states/ApiState';
|
import { apiUrl } from '../states/ApiState';
|
||||||
|
import { useModalState } from '../states/ModalState';
|
||||||
import { invalidResponse, permissionDenied } from './notifications';
|
import { invalidResponse, permissionDenied } from './notifications';
|
||||||
import { generateUniqueId } from './uid';
|
import { generateUniqueId } from './uid';
|
||||||
|
|
||||||
@ -97,6 +98,10 @@ export function openModalApiForm(props: ApiFormProps) {
|
|||||||
|
|
||||||
let url = constructFormUrl(props);
|
let url = constructFormUrl(props);
|
||||||
|
|
||||||
|
// let modalState = useModalState();
|
||||||
|
|
||||||
|
useModalState.getState().lock();
|
||||||
|
|
||||||
// Make OPTIONS request first
|
// Make OPTIONS request first
|
||||||
api
|
api
|
||||||
.options(url)
|
.options(url)
|
||||||
@ -119,6 +124,7 @@ export function openModalApiForm(props: ApiFormProps) {
|
|||||||
modals.open({
|
modals.open({
|
||||||
title: props.title,
|
title: props.title,
|
||||||
modalId: modalId,
|
modalId: modalId,
|
||||||
|
size: 'xl',
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
props.onClose ? props.onClose() : null;
|
props.onClose ? props.onClose() : null;
|
||||||
},
|
},
|
||||||
@ -126,8 +132,12 @@ export function openModalApiForm(props: ApiFormProps) {
|
|||||||
<ApiForm modalId={modalId} props={props} fieldDefinitions={fields} />
|
<ApiForm modalId={modalId} props={props} fieldDefinitions={fields} />
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useModalState.getState().unlock();
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
useModalState.getState().unlock();
|
||||||
|
|
||||||
console.log('Error:', error);
|
console.log('Error:', error);
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
invalidResponse(error.response.status);
|
invalidResponse(error.response.status);
|
||||||
|
57
src/frontend/src/functions/forms/CompanyForms.tsx
Normal file
57
src/frontend/src/functions/forms/CompanyForms.tsx
Normal 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
|
||||||
|
});
|
||||||
|
}
|
@ -3,16 +3,26 @@ import { Alert, LoadingOverlay, Stack, Text } from '@mantine/core';
|
|||||||
import {
|
import {
|
||||||
IconClipboardCheck,
|
IconClipboardCheck,
|
||||||
IconClipboardList,
|
IconClipboardList,
|
||||||
|
IconCopy,
|
||||||
|
IconDots,
|
||||||
|
IconEdit,
|
||||||
|
IconFileTypePdf,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
|
IconLink,
|
||||||
IconList,
|
IconList,
|
||||||
IconListCheck,
|
IconListCheck,
|
||||||
IconNotes,
|
IconNotes,
|
||||||
IconPaperclip,
|
IconPaperclip,
|
||||||
IconSitemap
|
IconPrinter,
|
||||||
|
IconQrcode,
|
||||||
|
IconSitemap,
|
||||||
|
IconTrash,
|
||||||
|
IconUnlink
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { ActionDropdown } from '../../components/items/ActionDropdown';
|
||||||
import {
|
import {
|
||||||
PlaceholderPanel,
|
PlaceholderPanel,
|
||||||
PlaceholderPill
|
PlaceholderPill
|
||||||
@ -25,6 +35,7 @@ import { StockItemTable } from '../../components/tables/stock/StockItemTable';
|
|||||||
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
|
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
|
||||||
import { useInstance } from '../../hooks/UseInstance';
|
import { useInstance } from '../../hooks/UseInstance';
|
||||||
import { ApiPaths, apiUrl } from '../../states/ApiState';
|
import { ApiPaths, apiUrl } from '../../states/ApiState';
|
||||||
|
import { useUserState } from '../../states/UserState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detail page for a single Build Order
|
* Detail page for a single Build Order
|
||||||
@ -44,6 +55,8 @@ export default function BuildDetail() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const user = useUserState();
|
||||||
|
|
||||||
const buildPanels: PanelType[] = useMemo(() => {
|
const buildPanels: PanelType[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -130,22 +143,78 @@ export default function BuildDetail() {
|
|||||||
];
|
];
|
||||||
}, [build]);
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack spacing="xs">
|
<Stack spacing="xs">
|
||||||
<PageDetail
|
<PageDetail
|
||||||
title={t`Build Order`}
|
title={t`Build Order`}
|
||||||
subtitle={build.reference}
|
subtitle={build.reference}
|
||||||
detail={
|
|
||||||
<Alert color="teal" title="Build order detail goes here">
|
|
||||||
<Text>TODO: Build details</Text>
|
|
||||||
</Alert>
|
|
||||||
}
|
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
{ name: t`Build Orders`, url: '/build' },
|
{ name: t`Build Orders`, url: '/build' },
|
||||||
{ name: build.reference, url: `/build/${build.pk}` }
|
{ name: build.reference, url: `/build/${build.pk}` }
|
||||||
]}
|
]}
|
||||||
actions={[<PlaceholderPill key="1" />]}
|
actions={buildActions}
|
||||||
/>
|
/>
|
||||||
<LoadingOverlay visible={instanceQuery.isFetching} />
|
<LoadingOverlay visible={instanceQuery.isFetching} />
|
||||||
<PanelGroup pageKey="build" panels={buildPanels} />
|
<PanelGroup pageKey="build" panels={buildPanels} />
|
||||||
|
226
src/frontend/src/pages/company/CompanyDetail.tsx
Normal file
226
src/frontend/src/pages/company/CompanyDetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
12
src/frontend/src/pages/company/CustomerDetail.tsx
Normal file
12
src/frontend/src/pages/company/CustomerDetail.tsx
Normal 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/' }]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
12
src/frontend/src/pages/company/ManufacturerDetail.tsx
Normal file
12
src/frontend/src/pages/company/ManufacturerDetail.tsx
Normal 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/' }]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
12
src/frontend/src/pages/company/SupplierDetail.tsx
Normal file
12
src/frontend/src/pages/company/SupplierDetail.tsx
Normal 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/' }]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -1,34 +1,37 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import {
|
import { Group, LoadingOverlay, Stack, Text } from '@mantine/core';
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
LoadingOverlay,
|
|
||||||
Stack,
|
|
||||||
Text
|
|
||||||
} from '@mantine/core';
|
|
||||||
import {
|
import {
|
||||||
IconBuilding,
|
IconBuilding,
|
||||||
|
IconCalendarStats,
|
||||||
|
IconClipboardList,
|
||||||
|
IconCopy,
|
||||||
IconCurrencyDollar,
|
IconCurrencyDollar,
|
||||||
|
IconDots,
|
||||||
|
IconEdit,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
IconLayersLinked,
|
IconLayersLinked,
|
||||||
|
IconLink,
|
||||||
IconList,
|
IconList,
|
||||||
IconListTree,
|
IconListTree,
|
||||||
IconNotes,
|
IconNotes,
|
||||||
IconPackages,
|
IconPackages,
|
||||||
IconPaperclip,
|
IconPaperclip,
|
||||||
|
IconQrcode,
|
||||||
IconShoppingCart,
|
IconShoppingCart,
|
||||||
IconStack2,
|
IconStack2,
|
||||||
IconTestPipe,
|
IconTestPipe,
|
||||||
IconTools,
|
IconTools,
|
||||||
|
IconTransfer,
|
||||||
|
IconTrash,
|
||||||
IconTruckDelivery,
|
IconTruckDelivery,
|
||||||
|
IconUnlink,
|
||||||
IconVersions
|
IconVersions
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { ApiImage } from '../../components/images/ApiImage';
|
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 { PageDetail } from '../../components/nav/PageDetail';
|
||||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||||
import { AttachmentTable } from '../../components/tables/general/AttachmentTable';
|
import { AttachmentTable } from '../../components/tables/general/AttachmentTable';
|
||||||
@ -40,6 +43,7 @@ import { NotesEditor } from '../../components/widgets/MarkdownEditor';
|
|||||||
import { editPart } from '../../functions/forms/PartForms';
|
import { editPart } from '../../functions/forms/PartForms';
|
||||||
import { useInstance } from '../../hooks/UseInstance';
|
import { useInstance } from '../../hooks/UseInstance';
|
||||||
import { ApiPaths, apiUrl } from '../../states/ApiState';
|
import { ApiPaths, apiUrl } from '../../states/ApiState';
|
||||||
|
import { useUserState } from '../../states/UserState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detail view for a single Part instance
|
* Detail view for a single Part instance
|
||||||
@ -47,6 +51,8 @@ import { ApiPaths, apiUrl } from '../../states/ApiState';
|
|||||||
export default function PartDetail() {
|
export default function PartDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|
||||||
|
const user = useUserState();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
instance: part,
|
instance: part,
|
||||||
refreshInstance,
|
refreshInstance,
|
||||||
@ -66,8 +72,7 @@ export default function PartDetail() {
|
|||||||
{
|
{
|
||||||
name: 'details',
|
name: 'details',
|
||||||
label: t`Details`,
|
label: t`Details`,
|
||||||
icon: <IconInfoCircle />,
|
icon: <IconInfoCircle />
|
||||||
content: <PlaceholderPanel />
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'parameters',
|
name: 'parameters',
|
||||||
@ -98,55 +103,57 @@ export default function PartDetail() {
|
|||||||
name: 'bom',
|
name: 'bom',
|
||||||
label: t`Bill of Materials`,
|
label: t`Bill of Materials`,
|
||||||
icon: <IconListTree />,
|
icon: <IconListTree />,
|
||||||
hidden: !part.assembly,
|
hidden: !part.assembly
|
||||||
content: <PlaceholderPanel />
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'builds',
|
name: 'builds',
|
||||||
label: t`Build Orders`,
|
label: t`Build Orders`,
|
||||||
icon: <IconTools />,
|
icon: <IconTools />,
|
||||||
hidden: !part.assembly && !part.component,
|
hidden: !part.assembly && !part.component
|
||||||
content: <PlaceholderPanel />
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'used_in',
|
name: 'used_in',
|
||||||
label: t`Used In`,
|
label: t`Used In`,
|
||||||
icon: <IconStack2 />,
|
icon: <IconStack2 />,
|
||||||
hidden: !part.component,
|
hidden: !part.component
|
||||||
content: <PlaceholderPanel />
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'pricing',
|
name: 'pricing',
|
||||||
label: t`Pricing`,
|
label: t`Pricing`,
|
||||||
icon: <IconCurrencyDollar />,
|
icon: <IconCurrencyDollar />
|
||||||
content: <PlaceholderPanel />
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'suppliers',
|
name: 'suppliers',
|
||||||
label: t`Suppliers`,
|
label: t`Suppliers`,
|
||||||
icon: <IconBuilding />,
|
icon: <IconBuilding />,
|
||||||
hidden: !part.purchaseable,
|
hidden: !part.purchaseable
|
||||||
content: <PlaceholderPanel />
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'purchase_orders',
|
name: 'purchase_orders',
|
||||||
label: t`Purchase Orders`,
|
label: t`Purchase Orders`,
|
||||||
icon: <IconShoppingCart />,
|
icon: <IconShoppingCart />,
|
||||||
content: <PlaceholderPanel />,
|
|
||||||
hidden: !part.purchaseable
|
hidden: !part.purchaseable
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'sales_orders',
|
name: 'sales_orders',
|
||||||
label: t`Sales Orders`,
|
label: t`Sales Orders`,
|
||||||
icon: <IconTruckDelivery />,
|
icon: <IconTruckDelivery />,
|
||||||
content: <PlaceholderPanel />,
|
|
||||||
hidden: !part.salable
|
hidden: !part.salable
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'scheduling',
|
||||||
|
label: t`Scheduling`,
|
||||||
|
icon: <IconCalendarStats />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'stocktake',
|
||||||
|
label: t`Stocktake`,
|
||||||
|
icon: <IconClipboardList />
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'test_templates',
|
name: 'test_templates',
|
||||||
label: t`Test Templates`,
|
label: t`Test Templates`,
|
||||||
icon: <IconTestPipe />,
|
icon: <IconTestPipe />,
|
||||||
content: <PlaceholderPanel />,
|
|
||||||
hidden: !part.trackable
|
hidden: !part.trackable
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -212,6 +219,79 @@ export default function PartDetail() {
|
|||||||
);
|
);
|
||||||
}, [part, id]);
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack spacing="xs">
|
<Stack spacing="xs">
|
||||||
@ -219,21 +299,7 @@ export default function PartDetail() {
|
|||||||
<PageDetail
|
<PageDetail
|
||||||
detail={partDetail}
|
detail={partDetail}
|
||||||
breadcrumbs={breadcrumbs}
|
breadcrumbs={breadcrumbs}
|
||||||
actions={[
|
actions={partActions}
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
color="blue"
|
|
||||||
onClick={() =>
|
|
||||||
part.pk &&
|
|
||||||
editPart({
|
|
||||||
part_id: part.pk,
|
|
||||||
callback: refreshInstance
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Edit Part
|
|
||||||
</Button>
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
<PanelGroup pageKey="part" panels={partPanels} />
|
<PanelGroup pageKey="part" panels={partPanels} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
@ -26,13 +26,23 @@ export default function PurchasingIndex() {
|
|||||||
name: 'suppliers',
|
name: 'suppliers',
|
||||||
label: t`Suppliers`,
|
label: t`Suppliers`,
|
||||||
icon: <IconBuildingStore />,
|
icon: <IconBuildingStore />,
|
||||||
content: <CompanyTable params={{ is_supplier: true }} />
|
content: (
|
||||||
|
<CompanyTable
|
||||||
|
path="purchasing/supplier"
|
||||||
|
params={{ is_supplier: true }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'manufacturer',
|
name: 'manufacturer',
|
||||||
label: t`Manufacturers`,
|
label: t`Manufacturers`,
|
||||||
icon: <IconBuildingFactory2 />,
|
icon: <IconBuildingFactory2 />,
|
||||||
content: <CompanyTable params={{ is_manufacturer: true }} />
|
content: (
|
||||||
|
<CompanyTable
|
||||||
|
path="purchasing/manufacturer"
|
||||||
|
params={{ is_manufacturer: true }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, []);
|
}, []);
|
||||||
|
@ -32,7 +32,9 @@ export default function PurchasingIndex() {
|
|||||||
name: 'suppliers',
|
name: 'suppliers',
|
||||||
label: t`Customers`,
|
label: t`Customers`,
|
||||||
icon: <IconBuildingStore />,
|
icon: <IconBuildingStore />,
|
||||||
content: <CompanyTable params={{ is_customer: true }} />
|
content: (
|
||||||
|
<CompanyTable path="sales/customer" params={{ is_customer: true }} />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, []);
|
}, []);
|
||||||
|
@ -12,6 +12,22 @@ export const Playground = Loadable(
|
|||||||
lazy(() => import('./pages/Index/Playground'))
|
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(
|
export const CategoryDetail = Loadable(
|
||||||
lazy(() => import('./pages/part/CategoryDetail'))
|
lazy(() => import('./pages/part/CategoryDetail'))
|
||||||
);
|
);
|
||||||
@ -109,9 +125,13 @@ export const routes = (
|
|||||||
</Route>
|
</Route>
|
||||||
<Route path="purchasing/">
|
<Route path="purchasing/">
|
||||||
<Route index element={<PurchasingIndex />} />
|
<Route index element={<PurchasingIndex />} />
|
||||||
|
<Route path="supplier/:id/" element={<SupplierDetail />} />
|
||||||
|
<Route path="manufacturer/:id/" element={<ManufacturerDetail />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="company/:id/" element={<CompanyDetail />} />
|
||||||
<Route path="sales/">
|
<Route path="sales/">
|
||||||
<Route index element={<SalesIndex />} />
|
<Route index element={<SalesIndex />} />
|
||||||
|
<Route path="customer/:id/" element={<CustomerDetail />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/profile/:tabValue" element={<Profile />} />
|
<Route path="/profile/:tabValue" element={<Profile />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
@ -55,6 +55,7 @@ export enum ApiPaths {
|
|||||||
|
|
||||||
// Company URLs
|
// Company URLs
|
||||||
company_list = 'api-company-list',
|
company_list = 'api-company-list',
|
||||||
|
company_attachment_list = 'api-company-attachment-list',
|
||||||
supplier_part_list = 'api-supplier-part-list',
|
supplier_part_list = 'api-supplier-part-list',
|
||||||
|
|
||||||
// Stock Item URLs
|
// Stock Item URLs
|
||||||
@ -137,6 +138,8 @@ export function apiEndpoint(path: ApiPaths): string {
|
|||||||
return 'part/attachment/';
|
return 'part/attachment/';
|
||||||
case ApiPaths.company_list:
|
case ApiPaths.company_list:
|
||||||
return 'company/';
|
return 'company/';
|
||||||
|
case ApiPaths.company_attachment_list:
|
||||||
|
return 'company/attachment/';
|
||||||
case ApiPaths.supplier_part_list:
|
case ApiPaths.supplier_part_list:
|
||||||
return 'company/part/';
|
return 'company/part/';
|
||||||
case ApiPaths.stock_item_list:
|
case ApiPaths.stock_item_list:
|
||||||
|
16
src/frontend/src/states/ModalState.tsx
Normal file
16
src/frontend/src/states/ModalState.tsx
Normal 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 }))
|
||||||
|
}));
|
@ -10,6 +10,7 @@ interface UserStateProps {
|
|||||||
username: () => string;
|
username: () => string;
|
||||||
setUser: (newUser: UserProps) => void;
|
setUser: (newUser: UserProps) => void;
|
||||||
fetchUserState: () => void;
|
fetchUserState: () => void;
|
||||||
|
checkUserRole: (role: string, permission: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
Reference in New Issue
Block a user