mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +00:00 
			
		
		
		
	Merge branch 'inventree:master' into matmair/issue5729
This commit is contained in:
		| @@ -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 | ||||
|   | ||||
| @@ -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'), | ||||
| ] | ||||
|   | ||||
| @@ -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 | ||||
| } | ||||
|   | ||||
							
								
								
									
										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, | ||||
|                                   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', | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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,7 +271,6 @@ export function ApiForm({ | ||||
|           </Alert> | ||||
|         )} | ||||
|         {preFormElement} | ||||
|         <ScrollArea> | ||||
|         <Stack spacing="xs"> | ||||
|           {Object.entries(props.fields ?? {}).map( | ||||
|             ([fieldName, field]) => | ||||
| @@ -294,7 +287,6 @@ export function ApiForm({ | ||||
|               ) | ||||
|           )} | ||||
|         </Stack> | ||||
|         </ScrollArea> | ||||
|         {postFormElement} | ||||
|       </Stack> | ||||
|       <Divider /> | ||||
|   | ||||
| @@ -19,7 +19,7 @@ export function Thumbnail({ | ||||
|  | ||||
|   return ( | ||||
|     <ApiImage | ||||
|       src={src} | ||||
|       src={src || '/static/img/blank_image.png'} | ||||
|       alt={alt} | ||||
|       width={size} | ||||
|       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 { 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 /> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -224,6 +224,7 @@ export function AttachmentTable({ | ||||
|  | ||||
|   return ( | ||||
|     <Stack spacing="xs"> | ||||
|       {pk && pk > 0 && ( | ||||
|         <InvenTreeTable | ||||
|           url={url} | ||||
|           tableKey={tableKey} | ||||
| @@ -238,6 +239,7 @@ export function AttachmentTable({ | ||||
|             } | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|       {allowEdit && validPk && ( | ||||
|         <Dropzone onDrop={uploadFiles}> | ||||
|           <Dropzone.Idle> | ||||
|   | ||||
| @@ -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}`); | ||||
|         } | ||||
|       }} | ||||
|     /> | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
							
								
								
									
										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 { | ||||
|   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} /> | ||||
|   | ||||
							
								
								
									
										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 { | ||||
|   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> | ||||
|   | ||||
| @@ -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 }} | ||||
|           /> | ||||
|         ) | ||||
|       } | ||||
|     ]; | ||||
|   }, []); | ||||
|   | ||||
| @@ -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 }} /> | ||||
|         ) | ||||
|       } | ||||
|     ]; | ||||
|   }, []); | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
							
								
								
									
										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; | ||||
|   setUser: (newUser: UserProps) => void; | ||||
|   fetchUserState: () => void; | ||||
|   checkUserRole: (role: string, permission: string) => boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|   | ||||
		Reference in New Issue
	
	Block a user