mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	Merge branch 'inventree:master' into matmair/issue5729
This commit is contained in:
		| @@ -2,11 +2,14 @@ | |||||||
|  |  | ||||||
|  |  | ||||||
| # InvenTree API version | # InvenTree API version | ||||||
| INVENTREE_API_VERSION = 138 | INVENTREE_API_VERSION = 139 | ||||||
|  |  | ||||||
| """ | """ | ||||||
| Increment this API version number whenever there is a significant change to the API that any clients need to know about | Increment this API version number whenever there is a significant change to the API that any clients need to know about | ||||||
|  |  | ||||||
|  | v139 -> 2023-10-11 : https://github.com/inventree/InvenTree/pull/5509 | ||||||
|  |     - Add new BarcodePOReceive endpoint to receive line items by scanning supplier barcodes | ||||||
|  |  | ||||||
| v138 -> 2023-10-11 : https://github.com/inventree/InvenTree/pull/5679 | v138 -> 2023-10-11 : https://github.com/inventree/InvenTree/pull/5679 | ||||||
|     - Settings keys are no longer case sensitive |     - Settings keys are no longer case sensitive | ||||||
|     - Include settings units in API serializer |     - Include settings units in API serializer | ||||||
|   | |||||||
| @@ -10,9 +10,11 @@ from rest_framework.response import Response | |||||||
| from rest_framework.views import APIView | from rest_framework.views import APIView | ||||||
|  |  | ||||||
| from InvenTree.helpers import hash_barcode | from InvenTree.helpers import hash_barcode | ||||||
|  | from order.models import PurchaseOrder | ||||||
| from plugin import registry | from plugin import registry | ||||||
| from plugin.builtin.barcodes.inventree_barcode import \ | from plugin.builtin.barcodes.inventree_barcode import \ | ||||||
|     InvenTreeInternalBarcodePlugin |     InvenTreeInternalBarcodePlugin | ||||||
|  | from stock.models import StockLocation | ||||||
| from users.models import RuleSet | from users.models import RuleSet | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -230,7 +232,7 @@ class BarcodeUnassign(APIView): | |||||||
|                 instance.unassign_barcode() |                 instance.unassign_barcode() | ||||||
|  |  | ||||||
|                 return Response({ |                 return Response({ | ||||||
|                     'success': 'Barcode unassigned from {label} instance', |                     'success': f'Barcode unassigned from {label} instance', | ||||||
|                 }) |                 }) | ||||||
|  |  | ||||||
|         # If we get to this point, something has gone wrong! |         # If we get to this point, something has gone wrong! | ||||||
| @@ -239,6 +241,84 @@ class BarcodeUnassign(APIView): | |||||||
|         }) |         }) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class BarcodePOReceive(APIView): | ||||||
|  |     """Endpoint for handling receiving parts by scanning their barcode. | ||||||
|  |  | ||||||
|  |     Barcode data are decoded by the client application, | ||||||
|  |     and sent to this endpoint (as a JSON object) for validation. | ||||||
|  |  | ||||||
|  |     The barcode should follow a third-party barcode format (e.g. Digikey) | ||||||
|  |     and ideally contain order_number and quantity information. | ||||||
|  |  | ||||||
|  |     The following parameters are available: | ||||||
|  |  | ||||||
|  |     - barcode: The raw barcode data (required) | ||||||
|  |     - purchase_order: The purchase order containing the item to receive (optional) | ||||||
|  |     - location: The destination location for the received item (optional) | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     permission_classes = [ | ||||||
|  |         permissions.IsAuthenticated, | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     def post(self, request, *args, **kwargs): | ||||||
|  |         """Respond to a barcode POST request.""" | ||||||
|  |  | ||||||
|  |         data = request.data | ||||||
|  |         if not (barcode_data := data.get("barcode")): | ||||||
|  |             raise ValidationError({"barcode": _("Missing barcode data")}) | ||||||
|  |  | ||||||
|  |         purchase_order = None | ||||||
|  |         if purchase_order_pk := data.get("purchase_order"): | ||||||
|  |             purchase_order = PurchaseOrder.objects.filter(pk=purchase_order_pk).first() | ||||||
|  |             if not purchase_order: | ||||||
|  |                 raise ValidationError({"purchase_order": _("Invalid purchase order")}) | ||||||
|  |  | ||||||
|  |         location = None | ||||||
|  |         if (location_pk := data.get("location")): | ||||||
|  |             location = StockLocation.objects.get(pk=location_pk) | ||||||
|  |             if not location: | ||||||
|  |                 raise ValidationError({"location": _("Invalid stock location")}) | ||||||
|  |  | ||||||
|  |         plugins = registry.with_mixin("barcode") | ||||||
|  |  | ||||||
|  |         # Look for a barcode plugin which knows how to deal with this barcode | ||||||
|  |         plugin = None | ||||||
|  |         response = {} | ||||||
|  |  | ||||||
|  |         internal_barcode_plugin = next(filter( | ||||||
|  |             lambda plugin: plugin.name == "InvenTreeBarcode", plugins)) | ||||||
|  |         if internal_barcode_plugin.scan(barcode_data): | ||||||
|  |             response["error"] = _("Item has already been received") | ||||||
|  |             raise ValidationError(response) | ||||||
|  |  | ||||||
|  |         for current_plugin in plugins: | ||||||
|  |             result = current_plugin.scan_receive_item( | ||||||
|  |                 barcode_data, | ||||||
|  |                 request.user, | ||||||
|  |                 purchase_order=purchase_order, | ||||||
|  |                 location=location, | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             if result is not None: | ||||||
|  |                 plugin = current_plugin | ||||||
|  |                 response = result | ||||||
|  |                 break | ||||||
|  |  | ||||||
|  |         response["plugin"] = plugin.name if plugin else None | ||||||
|  |         response["barcode_data"] = barcode_data | ||||||
|  |         response["barcode_hash"] = hash_barcode(barcode_data) | ||||||
|  |  | ||||||
|  |         # A plugin has not been found! | ||||||
|  |         if plugin is None: | ||||||
|  |             response["error"] = _("Invalid supplier barcode") | ||||||
|  |             raise ValidationError(response) | ||||||
|  |         elif "error" in response: | ||||||
|  |             raise ValidationError(response) | ||||||
|  |         else: | ||||||
|  |             return Response(response) | ||||||
|  |  | ||||||
|  |  | ||||||
| barcode_api_urls = [ | barcode_api_urls = [ | ||||||
|     # Link a third-party barcode to an item (e.g. Part / StockItem / etc) |     # Link a third-party barcode to an item (e.g. Part / StockItem / etc) | ||||||
|     path('link/', BarcodeAssign.as_view(), name='api-barcode-link'), |     path('link/', BarcodeAssign.as_view(), name='api-barcode-link'), | ||||||
| @@ -246,6 +326,9 @@ barcode_api_urls = [ | |||||||
|     # Unlink a third-pary barcode from an item |     # Unlink a third-pary barcode from an item | ||||||
|     path('unlink/', BarcodeUnassign.as_view(), name='api-barcode-unlink'), |     path('unlink/', BarcodeUnassign.as_view(), name='api-barcode-unlink'), | ||||||
|  |  | ||||||
|  |     # Receive a purchase order item by scanning its barcode | ||||||
|  |     path("po-receive/", BarcodePOReceive.as_view(), name="api-barcode-po-receive"), | ||||||
|  |  | ||||||
|     # Catch-all performs barcode 'scan' |     # Catch-all performs barcode 'scan' | ||||||
|     re_path(r'^.*$', BarcodeScan.as_view(), name='api-barcode-scan'), |     re_path(r'^.*$', BarcodeScan.as_view(), name='api-barcode-scan'), | ||||||
| ] | ] | ||||||
|   | |||||||
| @@ -1,5 +1,22 @@ | |||||||
| """Plugin mixin classes for barcode plugin.""" | """Plugin mixin classes for barcode plugin.""" | ||||||
|  |  | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | import logging | ||||||
|  | from dataclasses import dataclass | ||||||
|  | from decimal import Decimal, InvalidOperation | ||||||
|  |  | ||||||
|  | from django.contrib.auth.models import User | ||||||
|  | from django.db.models import F | ||||||
|  | from django.utils.translation import gettext_lazy as _ | ||||||
|  |  | ||||||
|  | from company.models import Company, SupplierPart | ||||||
|  | from order.models import PurchaseOrder, PurchaseOrderStatus | ||||||
|  | from plugin.base.integration.SettingsMixin import SettingsMixin | ||||||
|  | from stock.models import StockLocation | ||||||
|  |  | ||||||
|  | logger = logging.getLogger('inventree') | ||||||
|  |  | ||||||
|  |  | ||||||
| class BarcodeMixin: | class BarcodeMixin: | ||||||
|     """Mixin that enables barcode handling. |     """Mixin that enables barcode handling. | ||||||
| @@ -36,3 +53,334 @@ class BarcodeMixin: | |||||||
|         Default return value is None |         Default return value is None | ||||||
|         """ |         """ | ||||||
|         return None |         return None | ||||||
|  |  | ||||||
|  |     def scan_receive_item(self, barcode_data, user, purchase_order=None, location=None): | ||||||
|  |         """Scan a barcode to receive a purchase order item. | ||||||
|  |  | ||||||
|  |         It's recommended to use the receive_purchase_order_item method to return from this function. | ||||||
|  |  | ||||||
|  |         Returns: | ||||||
|  |             None if the barcode_data could not be parsed. | ||||||
|  |  | ||||||
|  |             A dict object containing: | ||||||
|  |                 - on success: | ||||||
|  |                     a "success" message and the received "lineitem" | ||||||
|  |                 - on partial success (if there's missing information): | ||||||
|  |                     an "action_required" message and the matched, but not yet received "lineitem" | ||||||
|  |                 - on failure: | ||||||
|  |                     an "error" message | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         return None | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def parse_ecia_barcode2d(barcode_data: str | list[str]) -> dict[str, str]: | ||||||
|  |         """Parse a standard ECIA 2D barcode, according to https://www.ecianow.org/assets/docs/ECIA_Specifications.pdf""" | ||||||
|  |  | ||||||
|  |         if not isinstance(barcode_data, str): | ||||||
|  |             data_split = barcode_data | ||||||
|  |         elif not (data_split := BarcodeMixin.parse_isoiec_15434_barcode2d(barcode_data)): | ||||||
|  |             return None | ||||||
|  |  | ||||||
|  |         barcode_fields = {} | ||||||
|  |         for entry in data_split: | ||||||
|  |             for identifier, field_name in ECIA_DATA_IDENTIFIER_MAP.items(): | ||||||
|  |                 if entry.startswith(identifier): | ||||||
|  |                     barcode_fields[field_name] = entry[len(identifier):] | ||||||
|  |                     break | ||||||
|  |  | ||||||
|  |         return barcode_fields | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def parse_isoiec_15434_barcode2d(barcode_data: str) -> list[str]: | ||||||
|  |         """Parse a ISO/IEC 15434 bardode, returning the split data section.""" | ||||||
|  |         HEADER = "[)>\x1E06\x1D" | ||||||
|  |         TRAILER = "\x1E\x04" | ||||||
|  |  | ||||||
|  |         # some old mouser barcodes start with this messed up header | ||||||
|  |         OLD_MOUSER_HEADER = ">[)>06\x1D" | ||||||
|  |         if barcode_data.startswith(OLD_MOUSER_HEADER): | ||||||
|  |             barcode_data = barcode_data.replace(OLD_MOUSER_HEADER, HEADER, 1) | ||||||
|  |  | ||||||
|  |         # most barcodes don't include the trailer, because "why would you stick to | ||||||
|  |         # the standard, right?" so we only check for the header here | ||||||
|  |         if not barcode_data.startswith(HEADER): | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         actual_data = barcode_data.split(HEADER, 1)[1].rsplit(TRAILER, 1)[0] | ||||||
|  |  | ||||||
|  |         return actual_data.split("\x1D") | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def get_supplier_parts(sku: str, supplier: Company = None, mpn: str = None): | ||||||
|  |         """Get a supplier part from SKU or by supplier and MPN.""" | ||||||
|  |         if not (sku or supplier or mpn): | ||||||
|  |             return SupplierPart.objects.none() | ||||||
|  |  | ||||||
|  |         supplier_parts = SupplierPart.objects.all() | ||||||
|  |  | ||||||
|  |         if sku: | ||||||
|  |             supplier_parts = supplier_parts.filter(SKU__iexact=sku) | ||||||
|  |             if len(supplier_parts) == 1: | ||||||
|  |                 return supplier_parts | ||||||
|  |  | ||||||
|  |         if supplier: | ||||||
|  |             supplier_parts = supplier_parts.filter(supplier=supplier.pk) | ||||||
|  |             if len(supplier_parts) == 1: | ||||||
|  |                 return supplier_parts | ||||||
|  |  | ||||||
|  |         if mpn: | ||||||
|  |             supplier_parts = SupplierPart.objects.filter(manufacturer_part__MPN__iexact=mpn) | ||||||
|  |             if len(supplier_parts) == 1: | ||||||
|  |                 return supplier_parts | ||||||
|  |  | ||||||
|  |         logger.warning( | ||||||
|  |             "Found %d supplier parts for SKU '%s', supplier '%s', MPN '%s'", | ||||||
|  |             supplier_parts.count(), | ||||||
|  |             sku, | ||||||
|  |             supplier.name if supplier else None, | ||||||
|  |             mpn, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         return supplier_parts | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def receive_purchase_order_item( | ||||||
|  |             supplier_part: SupplierPart, | ||||||
|  |             user: User, | ||||||
|  |             quantity: Decimal | str = None, | ||||||
|  |             order_number: str = None, | ||||||
|  |             purchase_order: PurchaseOrder = None, | ||||||
|  |             location: StockLocation = None, | ||||||
|  |             barcode: str = None, | ||||||
|  |     ) -> dict: | ||||||
|  |         """Try to receive a purchase order item. | ||||||
|  |  | ||||||
|  |         Returns: | ||||||
|  |             A dict object containing: | ||||||
|  |                 - on success: a "success" message | ||||||
|  |                 - on partial success: the "lineitem" with quantity and location (both can be None) | ||||||
|  |                 - on failure: an "error" message | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         if not purchase_order: | ||||||
|  |             # try to find a purchase order with either reference or name matching | ||||||
|  |             # the provided order_number | ||||||
|  |             if not order_number: | ||||||
|  |                 return {"error": _("Supplier barcode doesn't contain order number")} | ||||||
|  |  | ||||||
|  |             purchase_orders = ( | ||||||
|  |                 PurchaseOrder.objects.filter( | ||||||
|  |                     supplier_reference__iexact=order_number, | ||||||
|  |                     status=PurchaseOrderStatus.PLACED.value, | ||||||
|  |                 ) | PurchaseOrder.objects.filter( | ||||||
|  |                     reference__iexact=order_number, | ||||||
|  |                     status=PurchaseOrderStatus.PLACED.value, | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             if len(purchase_orders) > 1: | ||||||
|  |                 return {"error": _(f"Found multiple placed purchase orders for '{order_number}'")} | ||||||
|  |             elif not (purchase_order := purchase_orders.first()): | ||||||
|  |                 return {"error": _(f"Failed to find placed purchase order for '{order_number}'")} | ||||||
|  |  | ||||||
|  |         if quantity: | ||||||
|  |             try: | ||||||
|  |                 quantity = Decimal(quantity) | ||||||
|  |             except InvalidOperation: | ||||||
|  |                 logger.warning("Failed to parse quantity '%s'", quantity) | ||||||
|  |                 quantity = None | ||||||
|  |  | ||||||
|  |         #  find incomplete line_items that match the supplier_part | ||||||
|  |         line_items = purchase_order.lines.filter( | ||||||
|  |             part=supplier_part.pk, quantity__gt=F("received")) | ||||||
|  |         if len(line_items) == 1 or not quantity: | ||||||
|  |             line_item = line_items[0] | ||||||
|  |         else: | ||||||
|  |             # if there are multiple line items and the barcode contains a quantity: | ||||||
|  |             # 1. return the first line_item where line_item.quantity == quantity | ||||||
|  |             # 2. return the first line_item where line_item.quantity > quantity | ||||||
|  |             # 3. return the first line_item | ||||||
|  |             for line_item in line_items: | ||||||
|  |                 if line_item.quantity == quantity: | ||||||
|  |                     break | ||||||
|  |             else: | ||||||
|  |                 for line_item in line_items: | ||||||
|  |                     if line_item.quantity > quantity: | ||||||
|  |                         break | ||||||
|  |                 else: | ||||||
|  |                     line_item = line_items.first() | ||||||
|  |  | ||||||
|  |         if not line_item: | ||||||
|  |             return {"error": _("Failed to find pending line item for supplier part")} | ||||||
|  |  | ||||||
|  |         no_stock_locations = False | ||||||
|  |         if not location: | ||||||
|  |             # try to guess the destination were the stock_part should go | ||||||
|  |             # 1. check if it's defined on the line_item | ||||||
|  |             # 2. check if it's defined on the part | ||||||
|  |             # 3. check if there's 1 or 0 stock locations defined in InvenTree | ||||||
|  |             #    -> assume all stock is going into that location (or no location) | ||||||
|  |             if location := line_item.destination: | ||||||
|  |                 pass | ||||||
|  |             elif location := supplier_part.part.get_default_location(): | ||||||
|  |                 pass | ||||||
|  |             elif StockLocation.objects.count() <= 1: | ||||||
|  |                 if not (location := StockLocation.objects.first()): | ||||||
|  |                     no_stock_locations = True | ||||||
|  |  | ||||||
|  |         response = { | ||||||
|  |             "lineitem": { | ||||||
|  |                 "pk": line_item.pk, | ||||||
|  |                 "purchase_order": purchase_order.pk, | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if quantity: | ||||||
|  |             response["lineitem"]["quantity"] = quantity | ||||||
|  |         if location: | ||||||
|  |             response["lineitem"]["location"] = location.pk | ||||||
|  |  | ||||||
|  |         # if either the quantity is missing or no location is defined/found | ||||||
|  |         # -> return the line_item found, so the client can gather the missing | ||||||
|  |         #    information and complete the action with an 'api-po-receive' call | ||||||
|  |         if not quantity or (not location and not no_stock_locations): | ||||||
|  |             response["action_required"] = _("Further information required to receive line item") | ||||||
|  |             return response | ||||||
|  |  | ||||||
|  |         purchase_order.receive_line_item( | ||||||
|  |             line_item, | ||||||
|  |             location, | ||||||
|  |             quantity, | ||||||
|  |             user, | ||||||
|  |             barcode=barcode, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         response["success"] = _("Received purchase order line item") | ||||||
|  |         return response | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @dataclass | ||||||
|  | class SupplierBarcodeData: | ||||||
|  |     """Data parsed from a supplier barcode.""" | ||||||
|  |     SKU: str = None | ||||||
|  |     MPN: str = None | ||||||
|  |     quantity: Decimal | str = None | ||||||
|  |     order_number: str = None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SupplierBarcodeMixin(BarcodeMixin): | ||||||
|  |     """Mixin that provides default implementations for scan functions for supplier barcodes. | ||||||
|  |  | ||||||
|  |     Custom supplier barcode plugins should use this mixin and implement the | ||||||
|  |     parse_supplier_barcode_data function. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def parse_supplier_barcode_data(self, barcode_data) -> SupplierBarcodeData | None: | ||||||
|  |         """Get supplier_part and other barcode_fields from barcode data. | ||||||
|  |  | ||||||
|  |         Returns: | ||||||
|  |             None if the barcode_data is not from a valid barcode of the supplier. | ||||||
|  |  | ||||||
|  |             A SupplierBarcodeData object containing the SKU, MPN, quantity and order number | ||||||
|  |             if available. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         return None | ||||||
|  |  | ||||||
|  |     def scan(self, barcode_data): | ||||||
|  |         """Try to match a supplier barcode to a supplier part.""" | ||||||
|  |  | ||||||
|  |         if not (parsed := self.parse_supplier_barcode_data(barcode_data)): | ||||||
|  |             return None | ||||||
|  |         if parsed.SKU is None and parsed.MPN is None: | ||||||
|  |             return None | ||||||
|  |  | ||||||
|  |         supplier_parts = self.get_supplier_parts(parsed.SKU, self.get_supplier(), parsed.MPN) | ||||||
|  |         if len(supplier_parts) > 1: | ||||||
|  |             return {"error": _("Found multiple matching supplier parts for barcode")} | ||||||
|  |         elif not supplier_parts: | ||||||
|  |             return None | ||||||
|  |         supplier_part = supplier_parts[0] | ||||||
|  |  | ||||||
|  |         data = { | ||||||
|  |             "pk": supplier_part.pk, | ||||||
|  |             "api_url": f"{SupplierPart.get_api_url()}{supplier_part.pk}/", | ||||||
|  |             "web_url": supplier_part.get_absolute_url(), | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return {SupplierPart.barcode_model_type(): data} | ||||||
|  |  | ||||||
|  |     def scan_receive_item(self, barcode_data, user, purchase_order=None, location=None): | ||||||
|  |         """Try to scan a supplier barcode to receive a purchase order item.""" | ||||||
|  |  | ||||||
|  |         if not (parsed := self.parse_supplier_barcode_data(barcode_data)): | ||||||
|  |             return None | ||||||
|  |         if parsed.SKU is None and parsed.MPN is None: | ||||||
|  |             return None | ||||||
|  |  | ||||||
|  |         supplier_parts = self.get_supplier_parts(parsed.SKU, self.get_supplier(), parsed.MPN) | ||||||
|  |         if len(supplier_parts) > 1: | ||||||
|  |             return {"error": _("Found multiple matching supplier parts for barcode")} | ||||||
|  |         elif not supplier_parts: | ||||||
|  |             return None | ||||||
|  |         supplier_part = supplier_parts[0] | ||||||
|  |  | ||||||
|  |         return self.receive_purchase_order_item( | ||||||
|  |             supplier_part, | ||||||
|  |             user, | ||||||
|  |             quantity=parsed.quantity, | ||||||
|  |             order_number=parsed.order_number, | ||||||
|  |             purchase_order=purchase_order, | ||||||
|  |             location=location, | ||||||
|  |             barcode=barcode_data, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def get_supplier(self) -> Company | None: | ||||||
|  |         """Get the supplier for the SUPPLIER_ID set in the plugin settings. | ||||||
|  |  | ||||||
|  |         If it's not defined, try to guess it and set it if possible. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         if not isinstance(self, SettingsMixin): | ||||||
|  |             return None | ||||||
|  |  | ||||||
|  |         if supplier_pk := self.get_setting("SUPPLIER_ID"): | ||||||
|  |             if (supplier := Company.objects.get(pk=supplier_pk)): | ||||||
|  |                 return supplier | ||||||
|  |             else: | ||||||
|  |                 logger.error( | ||||||
|  |                     "No company with pk %d (set \"SUPPLIER_ID\" setting to a valid value)", | ||||||
|  |                     supplier_pk | ||||||
|  |                 ) | ||||||
|  |                 return None | ||||||
|  |  | ||||||
|  |         if not (supplier_name := getattr(self, "DEFAULT_SUPPLIER_NAME", None)): | ||||||
|  |             return None | ||||||
|  |  | ||||||
|  |         suppliers = Company.objects.filter(name__icontains=supplier_name, is_supplier=True) | ||||||
|  |         if len(suppliers) != 1: | ||||||
|  |             return None | ||||||
|  |         self.set_setting("SUPPLIER_ID", suppliers.first().pk) | ||||||
|  |  | ||||||
|  |         return suppliers.first() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # Map ECIA Data Identifier to human readable identifier | ||||||
|  | # The following identifiers haven't been implemented: 3S, 4S, 5S, S | ||||||
|  | ECIA_DATA_IDENTIFIER_MAP = { | ||||||
|  |     "K":   "purchase_order_number",     # noqa: E241 | ||||||
|  |     "1K":  "purchase_order_number",     # noqa: E241  DigiKey uses 1K instead of K | ||||||
|  |     "11K": "packing_list_number",       # noqa: E241 | ||||||
|  |     "6D":  "ship_date",                 # noqa: E241 | ||||||
|  |     "P":   "supplier_part_number",      # noqa: E241  "Customer Part Number" | ||||||
|  |     "1P":  "manufacturer_part_number",  # noqa: E241  "Supplier Part Number" | ||||||
|  |     "4K":  "purchase_order_line",       # noqa: E241 | ||||||
|  |     "14K": "purchase_order_line",       # noqa: E241  Mouser uses 14K instead of 4K | ||||||
|  |     "Q":   "quantity",                  # noqa: E241 | ||||||
|  |     "9D":  "date_yyww",                 # noqa: E241 | ||||||
|  |     "10D": "date_yyww",                 # noqa: E241 | ||||||
|  |     "1T":  "lot_code",                  # noqa: E241 | ||||||
|  |     "4L":  "country_of_origin",         # noqa: E241 | ||||||
|  |     "1V":  "manufacturer"               # noqa: E241 | ||||||
|  | } | ||||||
|   | |||||||
							
								
								
									
										0
									
								
								InvenTree/plugin/builtin/suppliers/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								InvenTree/plugin/builtin/suppliers/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										49
									
								
								InvenTree/plugin/builtin/suppliers/digikey.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								InvenTree/plugin/builtin/suppliers/digikey.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | |||||||
|  | """The DigiKeyPlugin is meant to integrate the DigiKey API into Inventree. | ||||||
|  |  | ||||||
|  | This plugin can currently only match DigiKey barcodes to supplier parts. | ||||||
|  | """ | ||||||
|  |  | ||||||
|  | import logging | ||||||
|  |  | ||||||
|  | from django.utils.translation import gettext_lazy as _ | ||||||
|  |  | ||||||
|  | from plugin import InvenTreePlugin | ||||||
|  | from plugin.base.barcodes.mixins import SupplierBarcodeData | ||||||
|  | from plugin.mixins import SettingsMixin, SupplierBarcodeMixin | ||||||
|  |  | ||||||
|  | logger = logging.getLogger('inventree') | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DigiKeyPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin): | ||||||
|  |     """Plugin to integrate the DigiKey API into Inventree.""" | ||||||
|  |  | ||||||
|  |     NAME = "DigiKeyPlugin" | ||||||
|  |     TITLE = _("Supplier Integration - DigiKey") | ||||||
|  |     DESCRIPTION = _("Provides support for scanning DigiKey barcodes") | ||||||
|  |     VERSION = "1.0.0" | ||||||
|  |     AUTHOR = _("InvenTree contributors") | ||||||
|  |  | ||||||
|  |     DEFAULT_SUPPLIER_NAME = "DigiKey" | ||||||
|  |     SETTINGS = { | ||||||
|  |         "SUPPLIER_ID": { | ||||||
|  |             "name": _("Supplier"), | ||||||
|  |             "description": _("The Supplier which acts as 'DigiKey'"), | ||||||
|  |             "model": "company.company", | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     def parse_supplier_barcode_data(self, barcode_data): | ||||||
|  |         """Get supplier_part and barcode_fields from DigiKey DataMatrix-Code.""" | ||||||
|  |  | ||||||
|  |         if not isinstance(barcode_data, str): | ||||||
|  |             return None | ||||||
|  |  | ||||||
|  |         if not (barcode_fields := self.parse_ecia_barcode2d(barcode_data)): | ||||||
|  |             return None | ||||||
|  |  | ||||||
|  |         return SupplierBarcodeData( | ||||||
|  |             SKU=barcode_fields.get("supplier_part_number"), | ||||||
|  |             MPN=barcode_fields.get("manufacturer_part_number"), | ||||||
|  |             quantity=barcode_fields.get("quantity"), | ||||||
|  |             order_number=barcode_fields.get("purchase_order_number"), | ||||||
|  |         ) | ||||||
							
								
								
									
										55
									
								
								InvenTree/plugin/builtin/suppliers/lcsc.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								InvenTree/plugin/builtin/suppliers/lcsc.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | |||||||
|  | """The LCSCPlugin is meant to integrate the LCSC API into Inventree. | ||||||
|  |  | ||||||
|  | This plugin can currently only match LCSC barcodes to supplier parts. | ||||||
|  | """ | ||||||
|  |  | ||||||
|  | import logging | ||||||
|  | import re | ||||||
|  |  | ||||||
|  | from django.utils.translation import gettext_lazy as _ | ||||||
|  |  | ||||||
|  | from plugin import InvenTreePlugin | ||||||
|  | from plugin.base.barcodes.mixins import SupplierBarcodeData | ||||||
|  | from plugin.mixins import SettingsMixin, SupplierBarcodeMixin | ||||||
|  |  | ||||||
|  | logger = logging.getLogger('inventree') | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class LCSCPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin): | ||||||
|  |     """Plugin to integrate the LCSC API into Inventree.""" | ||||||
|  |  | ||||||
|  |     NAME = "LCSCPlugin" | ||||||
|  |     TITLE = _("Supplier Integration - LCSC") | ||||||
|  |     DESCRIPTION = _("Provides support for scanning LCSC barcodes") | ||||||
|  |     VERSION = "1.0.0" | ||||||
|  |     AUTHOR = _("InvenTree contributors") | ||||||
|  |  | ||||||
|  |     DEFAULT_SUPPLIER_NAME = "LCSC" | ||||||
|  |     SETTINGS = { | ||||||
|  |         "SUPPLIER_ID": { | ||||||
|  |             "name": _("Supplier"), | ||||||
|  |             "description": _("The Supplier which acts as 'LCSC'"), | ||||||
|  |             "model": "company.company", | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     def parse_supplier_barcode_data(self, barcode_data): | ||||||
|  |         """Get supplier_part and barcode_fields from LCSC QR-Code.""" | ||||||
|  |  | ||||||
|  |         if not isinstance(barcode_data, str): | ||||||
|  |             return None | ||||||
|  |  | ||||||
|  |         if not (match := LCSC_BARCODE_REGEX.fullmatch(barcode_data)): | ||||||
|  |             return None | ||||||
|  |  | ||||||
|  |         barcode_fields = dict(pair.split(":") for pair in match.group(1).split(",")) | ||||||
|  |  | ||||||
|  |         return SupplierBarcodeData( | ||||||
|  |             SKU=barcode_fields.get("pc"), | ||||||
|  |             MPN=barcode_fields.get("pm"), | ||||||
|  |             quantity=barcode_fields.get("qty"), | ||||||
|  |             order_number=barcode_fields.get("on"), | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | LCSC_BARCODE_REGEX = re.compile(r"^{((?:[^:,]+:[^:,]*,)*(?:[^:,]+:[^:,]*))}$") | ||||||
							
								
								
									
										49
									
								
								InvenTree/plugin/builtin/suppliers/mouser.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								InvenTree/plugin/builtin/suppliers/mouser.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | |||||||
|  | """The MouserPlugin is meant to integrate the Mouser API into Inventree. | ||||||
|  |  | ||||||
|  | This plugin currently only match Mouser barcodes to supplier parts. | ||||||
|  | """ | ||||||
|  |  | ||||||
|  | import logging | ||||||
|  |  | ||||||
|  | from django.utils.translation import gettext_lazy as _ | ||||||
|  |  | ||||||
|  | from plugin import InvenTreePlugin | ||||||
|  | from plugin.base.barcodes.mixins import SupplierBarcodeData | ||||||
|  | from plugin.mixins import SettingsMixin, SupplierBarcodeMixin | ||||||
|  |  | ||||||
|  | logger = logging.getLogger('inventree') | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class MouserPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin): | ||||||
|  |     """Plugin to integrate the Mouser API into Inventree.""" | ||||||
|  |  | ||||||
|  |     NAME = "MouserPlugin" | ||||||
|  |     TITLE = _("Supplier Integration - Mouser") | ||||||
|  |     DESCRIPTION = _("Provides support for scanning Mouser barcodes") | ||||||
|  |     VERSION = "1.0.0" | ||||||
|  |     AUTHOR = _("InvenTree contributors") | ||||||
|  |  | ||||||
|  |     DEFAULT_SUPPLIER_NAME = "Mouser" | ||||||
|  |     SETTINGS = { | ||||||
|  |         "SUPPLIER_ID": { | ||||||
|  |             "name": _("Supplier"), | ||||||
|  |             "description": _("The Supplier which acts as 'Mouser'"), | ||||||
|  |             "model": "company.company", | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     def parse_supplier_barcode_data(self, barcode_data): | ||||||
|  |         """Get supplier_part and barcode_fields from Mouser DataMatrix-Code.""" | ||||||
|  |  | ||||||
|  |         if not isinstance(barcode_data, str): | ||||||
|  |             return None | ||||||
|  |  | ||||||
|  |         if not (barcode_fields := self.parse_ecia_barcode2d(barcode_data)): | ||||||
|  |             return None | ||||||
|  |  | ||||||
|  |         return SupplierBarcodeData( | ||||||
|  |             SKU=barcode_fields.get("supplier_part_number"), | ||||||
|  |             MPN=barcode_fields.get("manufacturer_part_number"), | ||||||
|  |             quantity=barcode_fields.get("quantity"), | ||||||
|  |             order_number=barcode_fields.get("purchase_order_number"), | ||||||
|  |         ) | ||||||
							
								
								
									
										308
									
								
								InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										308
									
								
								InvenTree/plugin/builtin/suppliers/test_supplier_barcodes.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,308 @@ | |||||||
|  | """Tests barcode parsing for all suppliers.""" | ||||||
|  |  | ||||||
|  | from django.urls import reverse | ||||||
|  |  | ||||||
|  | from company.models import Company, ManufacturerPart, SupplierPart | ||||||
|  | from InvenTree.unit_test import InvenTreeAPITestCase | ||||||
|  | from order.models import PurchaseOrder, PurchaseOrderLineItem | ||||||
|  | from part.models import Part | ||||||
|  | from stock.models import StockItem, StockLocation | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SupplierBarcodeTests(InvenTreeAPITestCase): | ||||||
|  |     """Tests barcode parsing for all suppliers.""" | ||||||
|  |  | ||||||
|  |     @classmethod | ||||||
|  |     def setUpTestData(cls): | ||||||
|  |         """Create supplier parts for barcodes.""" | ||||||
|  |         super().setUpTestData() | ||||||
|  |  | ||||||
|  |         part = Part.objects.create(name="Test Part", description="Test Part") | ||||||
|  |  | ||||||
|  |         manufacturer = Company.objects.create( | ||||||
|  |             name="Test Manufacturer", is_manufacturer=True) | ||||||
|  |  | ||||||
|  |         mpart1 = ManufacturerPart.objects.create( | ||||||
|  |             part=part, manufacturer=manufacturer, MPN="MC34063ADR") | ||||||
|  |         mpart2 = ManufacturerPart.objects.create( | ||||||
|  |             part=part, manufacturer=manufacturer, MPN="LDK320ADU33R") | ||||||
|  |  | ||||||
|  |         supplier = Company.objects.create(name="Supplier", is_supplier=True) | ||||||
|  |         mouser = Company.objects.create(name="Mouser Test", is_supplier=True) | ||||||
|  |  | ||||||
|  |         supplier_parts = [ | ||||||
|  |             SupplierPart(SKU="296-LM358BIDDFRCT-ND", part=part, supplier=supplier), | ||||||
|  |             SupplierPart(SKU="1", part=part, manufacturer_part=mpart1, supplier=mouser), | ||||||
|  |             SupplierPart(SKU="2", part=part, manufacturer_part=mpart2, supplier=mouser), | ||||||
|  |             SupplierPart(SKU="C312270", part=part, supplier=supplier), | ||||||
|  |             SupplierPart(SKU="WBP-302", part=part, supplier=supplier), | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |         SupplierPart.objects.bulk_create(supplier_parts) | ||||||
|  |  | ||||||
|  |     def test_digikey_barcode(self): | ||||||
|  |         """Test digikey barcode.""" | ||||||
|  |  | ||||||
|  |         url = reverse("api-barcode-scan") | ||||||
|  |         result = self.post(url, data={"barcode": DIGIKEY_BARCODE}) | ||||||
|  |  | ||||||
|  |         supplier_part_data = result.data.get("supplierpart") | ||||||
|  |         assert "pk" in supplier_part_data | ||||||
|  |         supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"]) | ||||||
|  |         assert supplier_part.SKU == "296-LM358BIDDFRCT-ND" | ||||||
|  |  | ||||||
|  |     def test_mouser_barcode(self): | ||||||
|  |         """Test mouser barcode with custom order number.""" | ||||||
|  |  | ||||||
|  |         url = reverse("api-barcode-scan") | ||||||
|  |         result = self.post(url, data={"barcode": MOUSER_BARCODE}) | ||||||
|  |  | ||||||
|  |         supplier_part_data = result.data.get("supplierpart") | ||||||
|  |         assert "pk" in supplier_part_data | ||||||
|  |         supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"]) | ||||||
|  |         assert supplier_part.SKU == "1" | ||||||
|  |  | ||||||
|  |     def test_old_mouser_barcode(self): | ||||||
|  |         """Test old mouser barcode with messed up header.""" | ||||||
|  |  | ||||||
|  |         url = reverse("api-barcode-scan") | ||||||
|  |         result = self.post(url, data={"barcode": MOUSER_BARCODE_OLD}) | ||||||
|  |  | ||||||
|  |         supplier_part_data = result.data.get("supplierpart") | ||||||
|  |         assert "pk" in supplier_part_data | ||||||
|  |         supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"]) | ||||||
|  |         assert supplier_part.SKU == "2" | ||||||
|  |  | ||||||
|  |     def test_lcsc_barcode(self): | ||||||
|  |         """Test LCSC barcode.""" | ||||||
|  |  | ||||||
|  |         url = reverse("api-barcode-scan") | ||||||
|  |         result = self.post(url, data={"barcode": LCSC_BARCODE}) | ||||||
|  |  | ||||||
|  |         supplier_part_data = result.data.get("supplierpart") | ||||||
|  |         assert supplier_part_data is not None | ||||||
|  |  | ||||||
|  |         assert "pk" in supplier_part_data | ||||||
|  |         supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"]) | ||||||
|  |         assert supplier_part.SKU == "C312270" | ||||||
|  |  | ||||||
|  |     def test_tme_qrcode(self): | ||||||
|  |         """Test TME QR-Code.""" | ||||||
|  |  | ||||||
|  |         url = reverse("api-barcode-scan") | ||||||
|  |         result = self.post(url, data={"barcode": TME_QRCODE}) | ||||||
|  |  | ||||||
|  |         supplier_part_data = result.data.get("supplierpart") | ||||||
|  |         assert supplier_part_data is not None | ||||||
|  |  | ||||||
|  |         assert "pk" in supplier_part_data | ||||||
|  |         supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"]) | ||||||
|  |         assert supplier_part.SKU == "WBP-302" | ||||||
|  |  | ||||||
|  |     def test_tme_barcode2d(self): | ||||||
|  |         """Test TME DataMatrix-Code.""" | ||||||
|  |  | ||||||
|  |         url = reverse("api-barcode-scan") | ||||||
|  |         result = self.post(url, data={"barcode": TME_DATAMATRIX_CODE}) | ||||||
|  |  | ||||||
|  |         supplier_part_data = result.data.get("supplierpart") | ||||||
|  |         assert supplier_part_data is not None | ||||||
|  |  | ||||||
|  |         assert "pk" in supplier_part_data | ||||||
|  |         supplier_part = SupplierPart.objects.get(pk=supplier_part_data["pk"]) | ||||||
|  |         assert supplier_part.SKU == "WBP-302" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase): | ||||||
|  |     """Tests barcode scanning to receive a purchase order item.""" | ||||||
|  |  | ||||||
|  |     def setUp(self): | ||||||
|  |         """Create supplier part and purchase_order.""" | ||||||
|  |         super().setUp() | ||||||
|  |  | ||||||
|  |         part = Part.objects.create(name="Test Part", description="Test Part") | ||||||
|  |         supplier = Company.objects.create(name="Supplier", is_supplier=True) | ||||||
|  |         manufacturer = Company.objects.create( | ||||||
|  |             name="Test Manufacturer", is_manufacturer=True) | ||||||
|  |  | ||||||
|  |         mouser = Company.objects.create(name="Mouser Test", is_supplier=True) | ||||||
|  |         mpart = ManufacturerPart.objects.create( | ||||||
|  |             part=part, manufacturer=manufacturer, MPN="MC34063ADR") | ||||||
|  |  | ||||||
|  |         self.purchase_order1 = PurchaseOrder.objects.create( | ||||||
|  |             supplier_reference="72991337", supplier=supplier) | ||||||
|  |         supplier_parts1 = [ | ||||||
|  |             SupplierPart(SKU=f"1_{i}", part=part, supplier=supplier) | ||||||
|  |             for i in range(6) | ||||||
|  |         ] | ||||||
|  |         supplier_parts1.insert( | ||||||
|  |             2, SupplierPart(SKU="296-LM358BIDDFRCT-ND", part=part, supplier=supplier)) | ||||||
|  |         for supplier_part in supplier_parts1: | ||||||
|  |             supplier_part.save() | ||||||
|  |             self.purchase_order1.add_line_item(supplier_part, 8) | ||||||
|  |  | ||||||
|  |         self.purchase_order2 = PurchaseOrder.objects.create( | ||||||
|  |             reference="P0-1337", supplier=mouser) | ||||||
|  |         self.purchase_order2.place_order() | ||||||
|  |         supplier_parts2 = [ | ||||||
|  |             SupplierPart(SKU=f"2_{i}", part=part, supplier=mouser) | ||||||
|  |             for i in range(6) | ||||||
|  |         ] | ||||||
|  |         supplier_parts2.insert( | ||||||
|  |             3, SupplierPart(SKU="42", part=part, manufacturer_part=mpart, supplier=mouser)) | ||||||
|  |         for supplier_part in supplier_parts2: | ||||||
|  |             supplier_part.save() | ||||||
|  |             self.purchase_order2.add_line_item(supplier_part, 5) | ||||||
|  |  | ||||||
|  |     def test_receive(self): | ||||||
|  |         """Test receiving an item from a barcode.""" | ||||||
|  |  | ||||||
|  |         url = reverse("api-barcode-po-receive") | ||||||
|  |  | ||||||
|  |         result1 = self.post(url, data={"barcode": DIGIKEY_BARCODE}) | ||||||
|  |         assert result1.status_code == 400 | ||||||
|  |         assert result1.data["error"].startswith("Failed to find placed purchase order") | ||||||
|  |  | ||||||
|  |         self.purchase_order1.place_order() | ||||||
|  |  | ||||||
|  |         result2 = self.post(url, data={"barcode": DIGIKEY_BARCODE}) | ||||||
|  |         assert result2.status_code == 200 | ||||||
|  |         assert "success" in result2.data | ||||||
|  |  | ||||||
|  |         result3 = self.post(url, data={"barcode": DIGIKEY_BARCODE}) | ||||||
|  |         assert result3.status_code == 400 | ||||||
|  |         assert result3.data["error"].startswith( | ||||||
|  |             "Item has already been received") | ||||||
|  |  | ||||||
|  |         result4 = self.post(url, data={"barcode": DIGIKEY_BARCODE[:-1]}) | ||||||
|  |         assert result4.status_code == 400 | ||||||
|  |         assert result4.data["error"].startswith( | ||||||
|  |             "Failed to find pending line item for supplier part") | ||||||
|  |  | ||||||
|  |         result5 = self.post(reverse("api-barcode-scan"), data={"barcode": DIGIKEY_BARCODE}) | ||||||
|  |         assert result5.status_code == 200 | ||||||
|  |         stock_item = StockItem.objects.get(pk=result5.data["stockitem"]["pk"]) | ||||||
|  |         assert stock_item.supplier_part.SKU == "296-LM358BIDDFRCT-ND" | ||||||
|  |         assert stock_item.quantity == 10 | ||||||
|  |         assert stock_item.location is None | ||||||
|  |  | ||||||
|  |     def test_receive_custom_order_number(self): | ||||||
|  |         """Test receiving an item from a barcode with a custom order number.""" | ||||||
|  |  | ||||||
|  |         url = reverse("api-barcode-po-receive") | ||||||
|  |         result1 = self.post(url, data={"barcode": MOUSER_BARCODE}) | ||||||
|  |         assert "success" in result1.data | ||||||
|  |  | ||||||
|  |         result2 = self.post(reverse("api-barcode-scan"), data={"barcode": MOUSER_BARCODE}) | ||||||
|  |         stock_item = StockItem.objects.get(pk=result2.data["stockitem"]["pk"]) | ||||||
|  |         assert stock_item.supplier_part.SKU == "42" | ||||||
|  |         assert stock_item.supplier_part.manufacturer_part.MPN == "MC34063ADR" | ||||||
|  |         assert stock_item.quantity == 3 | ||||||
|  |         assert stock_item.location is None | ||||||
|  |  | ||||||
|  |     def test_receive_one_stock_location(self): | ||||||
|  |         """Test receiving an item when only one stock location exists""" | ||||||
|  |  | ||||||
|  |         stock_location = StockLocation.objects.create(name="Test Location") | ||||||
|  |  | ||||||
|  |         url = reverse("api-barcode-po-receive") | ||||||
|  |         result1 = self.post(url, data={"barcode": MOUSER_BARCODE}) | ||||||
|  |         assert "success" in result1.data | ||||||
|  |  | ||||||
|  |         result2 = self.post(reverse("api-barcode-scan"), data={"barcode": MOUSER_BARCODE}) | ||||||
|  |         stock_item = StockItem.objects.get(pk=result2.data["stockitem"]["pk"]) | ||||||
|  |         assert stock_item.location == stock_location | ||||||
|  |  | ||||||
|  |     def test_receive_default_line_item_location(self): | ||||||
|  |         """Test receiving an item into the default line_item location""" | ||||||
|  |  | ||||||
|  |         StockLocation.objects.create(name="Test Location 1") | ||||||
|  |         stock_location2 = StockLocation.objects.create(name="Test Location 2") | ||||||
|  |  | ||||||
|  |         line_item = PurchaseOrderLineItem.objects.filter(part__SKU="42")[0] | ||||||
|  |         line_item.destination = stock_location2 | ||||||
|  |         line_item.save() | ||||||
|  |  | ||||||
|  |         url = reverse("api-barcode-po-receive") | ||||||
|  |         result1 = self.post(url, data={"barcode": MOUSER_BARCODE}) | ||||||
|  |         assert "success" in result1.data | ||||||
|  |  | ||||||
|  |         result2 = self.post(reverse("api-barcode-scan"), data={"barcode": MOUSER_BARCODE}) | ||||||
|  |         stock_item = StockItem.objects.get(pk=result2.data["stockitem"]["pk"]) | ||||||
|  |         assert stock_item.location == stock_location2 | ||||||
|  |  | ||||||
|  |     def test_receive_default_part_location(self): | ||||||
|  |         """Test receiving an item into the default part location""" | ||||||
|  |  | ||||||
|  |         StockLocation.objects.create(name="Test Location 1") | ||||||
|  |         stock_location2 = StockLocation.objects.create(name="Test Location 2") | ||||||
|  |  | ||||||
|  |         part = Part.objects.all()[0] | ||||||
|  |         part.default_location = stock_location2 | ||||||
|  |         part.save() | ||||||
|  |  | ||||||
|  |         url = reverse("api-barcode-po-receive") | ||||||
|  |         result1 = self.post(url, data={"barcode": MOUSER_BARCODE}) | ||||||
|  |         assert "success" in result1.data | ||||||
|  |  | ||||||
|  |         result2 = self.post(reverse("api-barcode-scan"), data={"barcode": MOUSER_BARCODE}) | ||||||
|  |         stock_item = StockItem.objects.get(pk=result2.data["stockitem"]["pk"]) | ||||||
|  |         assert stock_item.location == stock_location2 | ||||||
|  |  | ||||||
|  |     def test_receive_specific_order_and_location(self): | ||||||
|  |         """Test receiving an item from a specific order into a specific location""" | ||||||
|  |  | ||||||
|  |         StockLocation.objects.create(name="Test Location 1") | ||||||
|  |         stock_location2 = StockLocation.objects.create(name="Test Location 2") | ||||||
|  |  | ||||||
|  |         url = reverse("api-barcode-po-receive") | ||||||
|  |         barcode = MOUSER_BARCODE.replace("\x1dKP0-1337", "") | ||||||
|  |         result1 = self.post(url, data={ | ||||||
|  |             "barcode": barcode, | ||||||
|  |             "purchase_order": self.purchase_order2.pk, | ||||||
|  |             "location": stock_location2.pk, | ||||||
|  |         }) | ||||||
|  |         assert "success" in result1.data | ||||||
|  |  | ||||||
|  |         result2 = self.post(reverse("api-barcode-scan"), data={"barcode": barcode}) | ||||||
|  |         stock_item = StockItem.objects.get(pk=result2.data["stockitem"]["pk"]) | ||||||
|  |         assert stock_item.location == stock_location2 | ||||||
|  |  | ||||||
|  |     def test_receive_missing_quantity(self): | ||||||
|  |         """Test receiving an with missing quantity information""" | ||||||
|  |  | ||||||
|  |         url = reverse("api-barcode-po-receive") | ||||||
|  |         barcode = MOUSER_BARCODE.replace("\x1dQ3", "") | ||||||
|  |         result = self.post(url, data={"barcode": barcode}) | ||||||
|  |         assert "lineitem" in result.data | ||||||
|  |         assert "quantity" not in result.data["lineitem"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | DIGIKEY_BARCODE = ( | ||||||
|  |     "[)>\x1e06\x1dP296-LM358BIDDFRCT-ND\x1d1PLM358BIDDFR\x1dK\x1d1K72991337\x1d" | ||||||
|  |     "10K85781337\x1d11K1\x1d4LPH\x1dQ10\x1d11ZPICK\x1d12Z15221337\x1d13Z361337" | ||||||
|  |     "\x1d20Z0000000000000000000000000000000000000000000000000000000000000000000" | ||||||
|  |     "00000000000000000000000000000000000000000000000000000000000000000000000000" | ||||||
|  |     "0000000000000000000000000000000000" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | MOUSER_BARCODE = ( | ||||||
|  |     "[)>\x1e06\x1dKP0-1337\x1d14K011\x1d1PMC34063ADR\x1dQ3\x1d11K073121337\x1d4" | ||||||
|  |     "LMX\x1d1VTI\x1e\x04" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | MOUSER_BARCODE_OLD = ( | ||||||
|  |     ">[)>06\x1dK21421337\x1d14K033\x1d1PLDK320ADU33R\x1dQ32\x1d11K060931337\x1d" | ||||||
|  |     "4LCN\x1d1VSTMicro" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | LCSC_BARCODE = ( | ||||||
|  |     "{pbn:PICK2009291337,on:SO2009291337,pc:C312270,pm:ST-1-102-A01-T000-RS,qty" | ||||||
|  |     ":2,mc:,cc:1,pdi:34421807}" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | TME_QRCODE = ( | ||||||
|  |     "QTY:1 PN:WBP-302 PO:19361337/1 CPO:PO-2023-06-08-001337 MFR:WISHERENTERPRI" | ||||||
|  |     "SE MPN:WBP-302 RoHS https://www.tme.eu/details/WBP-302" | ||||||
|  | ) | ||||||
|  | TME_DATAMATRIX_CODE = "PWBP-302 1PMPNWBP-302 Q1 K19361337/1" | ||||||
							
								
								
									
										74
									
								
								InvenTree/plugin/builtin/suppliers/tme.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								InvenTree/plugin/builtin/suppliers/tme.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,74 @@ | |||||||
|  | """The TMEPlugin is meant to integrate the TME API into Inventree. | ||||||
|  |  | ||||||
|  | This plugin can currently only match TME barcodes to supplier parts. | ||||||
|  | """ | ||||||
|  |  | ||||||
|  | import logging | ||||||
|  | import re | ||||||
|  |  | ||||||
|  | from django.utils.translation import gettext_lazy as _ | ||||||
|  |  | ||||||
|  | from plugin import InvenTreePlugin | ||||||
|  | from plugin.base.barcodes.mixins import SupplierBarcodeData | ||||||
|  | from plugin.mixins import SettingsMixin, SupplierBarcodeMixin | ||||||
|  |  | ||||||
|  | logger = logging.getLogger('inventree') | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TMEPlugin(SupplierBarcodeMixin, SettingsMixin, InvenTreePlugin): | ||||||
|  |     """Plugin to integrate the TME API into Inventree.""" | ||||||
|  |  | ||||||
|  |     NAME = "TMEPlugin" | ||||||
|  |     TITLE = _("Supplier Integration - TME") | ||||||
|  |     DESCRIPTION = _("Provides support for scanning TME barcodes") | ||||||
|  |     VERSION = "1.0.0" | ||||||
|  |     AUTHOR = _("InvenTree contributors") | ||||||
|  |  | ||||||
|  |     DEFAULT_SUPPLIER_NAME = "TME" | ||||||
|  |     SETTINGS = { | ||||||
|  |         "SUPPLIER_ID": { | ||||||
|  |             "name": _("Supplier"), | ||||||
|  |             "description": _("The Supplier which acts as 'TME'"), | ||||||
|  |             "model": "company.company", | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     def parse_supplier_barcode_data(self, barcode_data): | ||||||
|  |         """Get supplier_part and barcode_fields from TME QR-Code or DataMatrix-Code.""" | ||||||
|  |  | ||||||
|  |         if not isinstance(barcode_data, str): | ||||||
|  |             return None | ||||||
|  |  | ||||||
|  |         if TME_IS_QRCODE_REGEX.fullmatch(barcode_data): | ||||||
|  |             barcode_fields = { | ||||||
|  |                 QRCODE_FIELD_NAME_MAP.get(field_name, field_name): value | ||||||
|  |                 for field_name, value in TME_PARSE_QRCODE_REGEX.findall(barcode_data) | ||||||
|  |             } | ||||||
|  |         elif TME_IS_BARCODE2D_REGEX.fullmatch(barcode_data): | ||||||
|  |             barcode_fields = self.parse_ecia_barcode2d( | ||||||
|  |                 TME_PARSE_BARCODE2D_REGEX.findall(barcode_data) | ||||||
|  |             ) | ||||||
|  |         else: | ||||||
|  |             return None | ||||||
|  |  | ||||||
|  |         if order_number := barcode_fields.get("purchase_order_number"): | ||||||
|  |             order_number = order_number.split("/")[0] | ||||||
|  |  | ||||||
|  |         return SupplierBarcodeData( | ||||||
|  |             SKU=barcode_fields.get("supplier_part_number"), | ||||||
|  |             MPN=barcode_fields.get("manufacturer_part_number"), | ||||||
|  |             quantity=barcode_fields.get("quantity"), | ||||||
|  |             order_number=order_number, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | TME_IS_QRCODE_REGEX = re.compile(r"([^\s:]+:[^\s:]+\s+)+(\S+(\s|$)+)+") | ||||||
|  | TME_PARSE_QRCODE_REGEX = re.compile(r"([^\s:]+):([^\s:]+)(?:\s+|$)") | ||||||
|  | TME_IS_BARCODE2D_REGEX = re.compile(r"(([^\s]+)(\s+|$))+") | ||||||
|  | TME_PARSE_BARCODE2D_REGEX = re.compile(r"([^\s]+)(?:\s+|$)") | ||||||
|  | QRCODE_FIELD_NAME_MAP = { | ||||||
|  |     "PN": "supplier_part_number", | ||||||
|  |     "PO": "purchase_order_number", | ||||||
|  |     "MPN": "manufacturer_part_number", | ||||||
|  |     "QTY": "quantity", | ||||||
|  | } | ||||||
| @@ -3,7 +3,7 @@ | |||||||
| from common.notifications import (BulkNotificationMethod, | from common.notifications import (BulkNotificationMethod, | ||||||
|                                   SingleNotificationMethod) |                                   SingleNotificationMethod) | ||||||
| from plugin.base.action.mixins import ActionMixin | from plugin.base.action.mixins import ActionMixin | ||||||
| from plugin.base.barcodes.mixins import BarcodeMixin | from plugin.base.barcodes.mixins import BarcodeMixin, SupplierBarcodeMixin | ||||||
| from plugin.base.event.mixins import EventMixin | from plugin.base.event.mixins import EventMixin | ||||||
| from plugin.base.integration.APICallMixin import APICallMixin | from plugin.base.integration.APICallMixin import APICallMixin | ||||||
| from plugin.base.integration.AppMixin import AppMixin | from plugin.base.integration.AppMixin import AppMixin | ||||||
| @@ -33,6 +33,7 @@ __all__ = [ | |||||||
|     'PanelMixin', |     'PanelMixin', | ||||||
|     'ActionMixin', |     'ActionMixin', | ||||||
|     'BarcodeMixin', |     'BarcodeMixin', | ||||||
|  |     'SupplierBarcodeMixin', | ||||||
|     'LocateMixin', |     'LocateMixin', | ||||||
|     'ValidationMixin', |     'ValidationMixin', | ||||||
|     'SingleNotificationMethod', |     'SingleNotificationMethod', | ||||||
|   | |||||||
| @@ -26,3 +26,16 @@ The barcode is tested as follows, in decreasing order of priority: | |||||||
|  |  | ||||||
| !!! tip "Plugin Loading Order" | !!! tip "Plugin Loading Order" | ||||||
|     The first custom plugin to return a result "wins". As the loading order of custom plugins is not defined (or configurable), take special care if you are running multiple plugins which support barcode actions. |     The first custom plugin to return a result "wins". As the loading order of custom plugins is not defined (or configurable), take special care if you are running multiple plugins which support barcode actions. | ||||||
|  |  | ||||||
|  | ## Builtin Supplier Barcode Plugins | ||||||
|  |  | ||||||
|  | InvenTree comes with a few builtin supplier plugins, which handle their respective barcode formats. | ||||||
|  |  | ||||||
|  | Scanning a supplier barcode for a supplied part will link to the corresponding supplier part if the [SKU](../report/context_variables.md#supplierpart) from the barcode could be matched. | ||||||
|  |  | ||||||
|  | The following suppliers (and barcode formats) are currently supported: | ||||||
|  |  | ||||||
|  | - DigiKey (2D Data Matrix code) | ||||||
|  | - Mouser (2D Data Matrix code) | ||||||
|  | - LCSC (QR code) | ||||||
|  | - TME (QR code & 2D Data Matrix code) | ||||||
|   | |||||||
| @@ -1,11 +1,5 @@ | |||||||
| import { t } from '@lingui/macro'; | import { t } from '@lingui/macro'; | ||||||
| import { | import { Alert, Divider, LoadingOverlay, Text } from '@mantine/core'; | ||||||
|   Alert, |  | ||||||
|   Divider, |  | ||||||
|   LoadingOverlay, |  | ||||||
|   ScrollArea, |  | ||||||
|   Text |  | ||||||
| } from '@mantine/core'; |  | ||||||
| import { Button, Group, Stack } from '@mantine/core'; | import { Button, Group, Stack } from '@mantine/core'; | ||||||
| import { useForm } from '@mantine/form'; | import { useForm } from '@mantine/form'; | ||||||
| import { modals } from '@mantine/modals'; | import { modals } from '@mantine/modals'; | ||||||
| @@ -277,24 +271,22 @@ export function ApiForm({ | |||||||
|           </Alert> |           </Alert> | ||||||
|         )} |         )} | ||||||
|         {preFormElement} |         {preFormElement} | ||||||
|         <ScrollArea> |         <Stack spacing="xs"> | ||||||
|           <Stack spacing="xs"> |           {Object.entries(props.fields ?? {}).map( | ||||||
|             {Object.entries(props.fields ?? {}).map( |             ([fieldName, field]) => | ||||||
|               ([fieldName, field]) => |               !field.hidden && ( | ||||||
|                 !field.hidden && ( |                 <ApiFormField | ||||||
|                   <ApiFormField |                   key={fieldName} | ||||||
|                     key={fieldName} |                   field={field} | ||||||
|                     field={field} |                   fieldName={fieldName} | ||||||
|                     fieldName={fieldName} |                   formProps={props} | ||||||
|                     formProps={props} |                   form={form} | ||||||
|                     form={form} |                   error={form.errors[fieldName] ?? null} | ||||||
|                     error={form.errors[fieldName] ?? null} |                   definitions={fieldDefinitions} | ||||||
|                     definitions={fieldDefinitions} |                 /> | ||||||
|                   /> |               ) | ||||||
|                 ) |           )} | ||||||
|             )} |         </Stack> | ||||||
|           </Stack> |  | ||||||
|         </ScrollArea> |  | ||||||
|         {postFormElement} |         {postFormElement} | ||||||
|       </Stack> |       </Stack> | ||||||
|       <Divider /> |       <Divider /> | ||||||
|   | |||||||
| @@ -19,7 +19,7 @@ export function Thumbnail({ | |||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <ApiImage |     <ApiImage | ||||||
|       src={src} |       src={src || '/static/img/blank_image.png'} | ||||||
|       alt={alt} |       alt={alt} | ||||||
|       width={size} |       width={size} | ||||||
|       fit="contain" |       fit="contain" | ||||||
|   | |||||||
							
								
								
									
										65
									
								
								src/frontend/src/components/items/ActionDropdown.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								src/frontend/src/components/items/ActionDropdown.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | |||||||
|  | import { ActionIcon, Menu, Tooltip } from '@mantine/core'; | ||||||
|  | import { ReactNode, useMemo } from 'react'; | ||||||
|  |  | ||||||
|  | import { notYetImplemented } from '../../functions/notifications'; | ||||||
|  |  | ||||||
|  | export type ActionDropdownItem = { | ||||||
|  |   icon: ReactNode; | ||||||
|  |   name: string; | ||||||
|  |   tooltip?: string; | ||||||
|  |   disabled?: boolean; | ||||||
|  |   onClick?: () => void; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * A simple Menu component which renders a set of actions. | ||||||
|  |  * | ||||||
|  |  * If no "active" actions are provided, the menu will not be rendered | ||||||
|  |  */ | ||||||
|  | export function ActionDropdown({ | ||||||
|  |   icon, | ||||||
|  |   tooltip, | ||||||
|  |   actions | ||||||
|  | }: { | ||||||
|  |   icon: ReactNode; | ||||||
|  |   tooltip?: string; | ||||||
|  |   actions: ActionDropdownItem[]; | ||||||
|  | }) { | ||||||
|  |   const hasActions = useMemo(() => { | ||||||
|  |     return actions.some((action) => !action.disabled); | ||||||
|  |   }, [actions]); | ||||||
|  |  | ||||||
|  |   return hasActions ? ( | ||||||
|  |     <Menu position="bottom-end"> | ||||||
|  |       <Menu.Target> | ||||||
|  |         <Tooltip label={tooltip}> | ||||||
|  |           <ActionIcon size="lg" radius="sm" variant="outline"> | ||||||
|  |             {icon} | ||||||
|  |           </ActionIcon> | ||||||
|  |         </Tooltip> | ||||||
|  |       </Menu.Target> | ||||||
|  |       <Menu.Dropdown> | ||||||
|  |         {actions.map((action, index) => | ||||||
|  |           action.disabled ? null : ( | ||||||
|  |             <Tooltip label={action.tooltip}> | ||||||
|  |               <Menu.Item | ||||||
|  |                 icon={action.icon} | ||||||
|  |                 key={index} | ||||||
|  |                 onClick={() => { | ||||||
|  |                   if (action.onClick != undefined) { | ||||||
|  |                     action.onClick(); | ||||||
|  |                   } else { | ||||||
|  |                     notYetImplemented(); | ||||||
|  |                   } | ||||||
|  |                 }} | ||||||
|  |                 disabled={action.disabled} | ||||||
|  |               > | ||||||
|  |                 {action.name} | ||||||
|  |               </Menu.Item> | ||||||
|  |             </Tooltip> | ||||||
|  |           ) | ||||||
|  |         )} | ||||||
|  |       </Menu.Dropdown> | ||||||
|  |     </Menu> | ||||||
|  |   ) : null; | ||||||
|  | } | ||||||
| @@ -1,7 +1,8 @@ | |||||||
| import { Container, Flex, Space } from '@mantine/core'; | import { Container, Flex, LoadingOverlay, Space } from '@mantine/core'; | ||||||
| import { Navigate, Outlet } from 'react-router-dom'; | import { Navigate, Outlet } from 'react-router-dom'; | ||||||
|  |  | ||||||
| import { InvenTreeStyle } from '../../globalStyle'; | import { InvenTreeStyle } from '../../globalStyle'; | ||||||
|  | import { useModalState } from '../../states/ModalState'; | ||||||
| import { useSessionState } from '../../states/SessionState'; | import { useSessionState } from '../../states/SessionState'; | ||||||
| import { Footer } from './Footer'; | import { Footer } from './Footer'; | ||||||
| import { Header } from './Header'; | import { Header } from './Header'; | ||||||
| @@ -19,9 +20,12 @@ export const ProtectedRoute = ({ children }: { children: JSX.Element }) => { | |||||||
| export default function LayoutComponent() { | export default function LayoutComponent() { | ||||||
|   const { classes } = InvenTreeStyle(); |   const { classes } = InvenTreeStyle(); | ||||||
|  |  | ||||||
|  |   const modalState = useModalState(); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <ProtectedRoute> |     <ProtectedRoute> | ||||||
|       <Flex direction="column" mih="100vh"> |       <Flex direction="column" mih="100vh"> | ||||||
|  |         <LoadingOverlay visible={modalState.loading} /> | ||||||
|         <Header /> |         <Header /> | ||||||
|         <Container className={classes.layoutContent} size="100%"> |         <Container className={classes.layoutContent} size="100%"> | ||||||
|           <Outlet /> |           <Outlet /> | ||||||
|   | |||||||
| @@ -41,7 +41,11 @@ export function PageDetail({ | |||||||
|               </Stack> |               </Stack> | ||||||
|             </Group> |             </Group> | ||||||
|             <Space /> |             <Space /> | ||||||
|             {actions && <Group position="right">{actions}</Group>} |             {actions && ( | ||||||
|  |               <Group spacing={5} position="right"> | ||||||
|  |                 {actions} | ||||||
|  |               </Group> | ||||||
|  |             )} | ||||||
|           </Group> |           </Group> | ||||||
|         </Stack> |         </Stack> | ||||||
|       </Paper> |       </Paper> | ||||||
|   | |||||||
| @@ -224,20 +224,22 @@ export function AttachmentTable({ | |||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Stack spacing="xs"> |     <Stack spacing="xs"> | ||||||
|       <InvenTreeTable |       {pk && pk > 0 && ( | ||||||
|         url={url} |         <InvenTreeTable | ||||||
|         tableKey={tableKey} |           url={url} | ||||||
|         columns={tableColumns} |           tableKey={tableKey} | ||||||
|         props={{ |           columns={tableColumns} | ||||||
|           noRecordsText: t`No attachments found`, |           props={{ | ||||||
|           enableSelection: true, |             noRecordsText: t`No attachments found`, | ||||||
|           customActionGroups: customActionGroups, |             enableSelection: true, | ||||||
|           rowActions: allowEdit && allowDelete ? rowActions : undefined, |             customActionGroups: customActionGroups, | ||||||
|           params: { |             rowActions: allowEdit && allowDelete ? rowActions : undefined, | ||||||
|             [model]: pk |             params: { | ||||||
|           } |               [model]: pk | ||||||
|         }} |             } | ||||||
|       /> |           }} | ||||||
|  |         /> | ||||||
|  |       )} | ||||||
|       {allowEdit && validPk && ( |       {allowEdit && validPk && ( | ||||||
|         <Dropzone onDrop={uploadFiles}> |         <Dropzone onDrop={uploadFiles}> | ||||||
|           <Dropzone.Idle> |           <Dropzone.Idle> | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| import { t } from '@lingui/macro'; | import { t } from '@lingui/macro'; | ||||||
| import { Group, Text } from '@mantine/core'; | import { Group, Text } from '@mantine/core'; | ||||||
| import { useMemo } from 'react'; | import { useMemo } from 'react'; | ||||||
|  | import { useNavigate } from 'react-router-dom'; | ||||||
|  |  | ||||||
| import { useTableRefresh } from '../../../hooks/TableRefresh'; | import { useTableRefresh } from '../../../hooks/TableRefresh'; | ||||||
| import { ApiPaths, apiUrl } from '../../../states/ApiState'; | import { ApiPaths, apiUrl } from '../../../states/ApiState'; | ||||||
| @@ -11,9 +12,17 @@ import { InvenTreeTable } from '../InvenTreeTable'; | |||||||
|  * A table which displays a list of company records, |  * A table which displays a list of company records, | ||||||
|  * based on the provided filter parameters |  * based on the provided filter parameters | ||||||
|  */ |  */ | ||||||
| export function CompanyTable({ params }: { params?: any }) { | export function CompanyTable({ | ||||||
|  |   params, | ||||||
|  |   path | ||||||
|  | }: { | ||||||
|  |   params?: any; | ||||||
|  |   path?: string; | ||||||
|  | }) { | ||||||
|   const { tableKey } = useTableRefresh('company'); |   const { tableKey } = useTableRefresh('company'); | ||||||
|  |  | ||||||
|  |   const navigate = useNavigate(); | ||||||
|  |  | ||||||
|   const columns = useMemo(() => { |   const columns = useMemo(() => { | ||||||
|     return [ |     return [ | ||||||
|       { |       { | ||||||
| @@ -24,7 +33,7 @@ export function CompanyTable({ params }: { params?: any }) { | |||||||
|           return ( |           return ( | ||||||
|             <Group spacing="xs" noWrap={true}> |             <Group spacing="xs" noWrap={true}> | ||||||
|               <Thumbnail |               <Thumbnail | ||||||
|                 src={record.thumbnail ?? record.image} |                 src={record.thumbnail ?? record.image ?? ''} | ||||||
|                 alt={record.name} |                 alt={record.name} | ||||||
|                 size={24} |                 size={24} | ||||||
|               /> |               /> | ||||||
| @@ -56,6 +65,10 @@ export function CompanyTable({ params }: { params?: any }) { | |||||||
|       props={{ |       props={{ | ||||||
|         params: { |         params: { | ||||||
|           ...params |           ...params | ||||||
|  |         }, | ||||||
|  |         onRowClick: (row: any) => { | ||||||
|  |           let base = path ?? 'company'; | ||||||
|  |           navigate(`/${base}/${row.pk}`); | ||||||
|         } |         } | ||||||
|       }} |       }} | ||||||
|     /> |     /> | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ import { api } from '../App'; | |||||||
| import { ApiForm, ApiFormProps } from '../components/forms/ApiForm'; | import { ApiForm, ApiFormProps } from '../components/forms/ApiForm'; | ||||||
| import { ApiFormFieldType } from '../components/forms/fields/ApiFormField'; | import { ApiFormFieldType } from '../components/forms/fields/ApiFormField'; | ||||||
| import { apiUrl } from '../states/ApiState'; | import { apiUrl } from '../states/ApiState'; | ||||||
|  | import { useModalState } from '../states/ModalState'; | ||||||
| import { invalidResponse, permissionDenied } from './notifications'; | import { invalidResponse, permissionDenied } from './notifications'; | ||||||
| import { generateUniqueId } from './uid'; | import { generateUniqueId } from './uid'; | ||||||
|  |  | ||||||
| @@ -97,6 +98,10 @@ export function openModalApiForm(props: ApiFormProps) { | |||||||
|  |  | ||||||
|   let url = constructFormUrl(props); |   let url = constructFormUrl(props); | ||||||
|  |  | ||||||
|  |   // let modalState = useModalState(); | ||||||
|  |  | ||||||
|  |   useModalState.getState().lock(); | ||||||
|  |  | ||||||
|   // Make OPTIONS request first |   // Make OPTIONS request first | ||||||
|   api |   api | ||||||
|     .options(url) |     .options(url) | ||||||
| @@ -119,6 +124,7 @@ export function openModalApiForm(props: ApiFormProps) { | |||||||
|       modals.open({ |       modals.open({ | ||||||
|         title: props.title, |         title: props.title, | ||||||
|         modalId: modalId, |         modalId: modalId, | ||||||
|  |         size: 'xl', | ||||||
|         onClose: () => { |         onClose: () => { | ||||||
|           props.onClose ? props.onClose() : null; |           props.onClose ? props.onClose() : null; | ||||||
|         }, |         }, | ||||||
| @@ -126,8 +132,12 @@ export function openModalApiForm(props: ApiFormProps) { | |||||||
|           <ApiForm modalId={modalId} props={props} fieldDefinitions={fields} /> |           <ApiForm modalId={modalId} props={props} fieldDefinitions={fields} /> | ||||||
|         ) |         ) | ||||||
|       }); |       }); | ||||||
|  |  | ||||||
|  |       useModalState.getState().unlock(); | ||||||
|     }) |     }) | ||||||
|     .catch((error) => { |     .catch((error) => { | ||||||
|  |       useModalState.getState().unlock(); | ||||||
|  |  | ||||||
|       console.log('Error:', error); |       console.log('Error:', error); | ||||||
|       if (error.response) { |       if (error.response) { | ||||||
|         invalidResponse(error.response.status); |         invalidResponse(error.response.status); | ||||||
|   | |||||||
							
								
								
									
										57
									
								
								src/frontend/src/functions/forms/CompanyForms.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/frontend/src/functions/forms/CompanyForms.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | |||||||
|  | import { t } from '@lingui/macro'; | ||||||
|  | import { | ||||||
|  |   IconAt, | ||||||
|  |   IconCurrencyDollar, | ||||||
|  |   IconGlobe, | ||||||
|  |   IconPhone | ||||||
|  | } from '@tabler/icons-react'; | ||||||
|  |  | ||||||
|  | import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField'; | ||||||
|  | import { ApiPaths } from '../../states/ApiState'; | ||||||
|  | import { openEditApiForm } from '../forms'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Field set for editing a company instance | ||||||
|  |  */ | ||||||
|  | export function companyFields(): ApiFormFieldSet { | ||||||
|  |   return { | ||||||
|  |     name: {}, | ||||||
|  |     description: {}, | ||||||
|  |     website: { | ||||||
|  |       icon: <IconGlobe /> | ||||||
|  |     }, | ||||||
|  |     currency: { | ||||||
|  |       icon: <IconCurrencyDollar /> | ||||||
|  |     }, | ||||||
|  |     phone: { | ||||||
|  |       icon: <IconPhone /> | ||||||
|  |     }, | ||||||
|  |     email: { | ||||||
|  |       icon: <IconAt /> | ||||||
|  |     }, | ||||||
|  |     is_supplier: {}, | ||||||
|  |     is_manufacturer: {}, | ||||||
|  |     is_customer: {} | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Edit a company instance | ||||||
|  |  */ | ||||||
|  | export function editCompany({ | ||||||
|  |   pk, | ||||||
|  |   callback | ||||||
|  | }: { | ||||||
|  |   pk: number; | ||||||
|  |   callback?: () => void; | ||||||
|  | }) { | ||||||
|  |   openEditApiForm({ | ||||||
|  |     name: 'company-edit', | ||||||
|  |     title: t`Edit Company`, | ||||||
|  |     url: ApiPaths.company_list, | ||||||
|  |     pk: pk, | ||||||
|  |     fields: companyFields(), | ||||||
|  |     successMessage: t`Company updated`, | ||||||
|  |     onFormSuccess: callback | ||||||
|  |   }); | ||||||
|  | } | ||||||
| @@ -3,16 +3,26 @@ import { Alert, LoadingOverlay, Stack, Text } from '@mantine/core'; | |||||||
| import { | import { | ||||||
|   IconClipboardCheck, |   IconClipboardCheck, | ||||||
|   IconClipboardList, |   IconClipboardList, | ||||||
|  |   IconCopy, | ||||||
|  |   IconDots, | ||||||
|  |   IconEdit, | ||||||
|  |   IconFileTypePdf, | ||||||
|   IconInfoCircle, |   IconInfoCircle, | ||||||
|  |   IconLink, | ||||||
|   IconList, |   IconList, | ||||||
|   IconListCheck, |   IconListCheck, | ||||||
|   IconNotes, |   IconNotes, | ||||||
|   IconPaperclip, |   IconPaperclip, | ||||||
|   IconSitemap |   IconPrinter, | ||||||
|  |   IconQrcode, | ||||||
|  |   IconSitemap, | ||||||
|  |   IconTrash, | ||||||
|  |   IconUnlink | ||||||
| } from '@tabler/icons-react'; | } from '@tabler/icons-react'; | ||||||
| import { useMemo } from 'react'; | import { useMemo } from 'react'; | ||||||
| import { useParams } from 'react-router-dom'; | import { useParams } from 'react-router-dom'; | ||||||
|  |  | ||||||
|  | import { ActionDropdown } from '../../components/items/ActionDropdown'; | ||||||
| import { | import { | ||||||
|   PlaceholderPanel, |   PlaceholderPanel, | ||||||
|   PlaceholderPill |   PlaceholderPill | ||||||
| @@ -25,6 +35,7 @@ import { StockItemTable } from '../../components/tables/stock/StockItemTable'; | |||||||
| import { NotesEditor } from '../../components/widgets/MarkdownEditor'; | import { NotesEditor } from '../../components/widgets/MarkdownEditor'; | ||||||
| import { useInstance } from '../../hooks/UseInstance'; | import { useInstance } from '../../hooks/UseInstance'; | ||||||
| import { ApiPaths, apiUrl } from '../../states/ApiState'; | import { ApiPaths, apiUrl } from '../../states/ApiState'; | ||||||
|  | import { useUserState } from '../../states/UserState'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Detail page for a single Build Order |  * Detail page for a single Build Order | ||||||
| @@ -44,6 +55,8 @@ export default function BuildDetail() { | |||||||
|     } |     } | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|  |   const user = useUserState(); | ||||||
|  |  | ||||||
|   const buildPanels: PanelType[] = useMemo(() => { |   const buildPanels: PanelType[] = useMemo(() => { | ||||||
|     return [ |     return [ | ||||||
|       { |       { | ||||||
| @@ -130,22 +143,78 @@ export default function BuildDetail() { | |||||||
|     ]; |     ]; | ||||||
|   }, [build]); |   }, [build]); | ||||||
|  |  | ||||||
|  |   const buildActions = useMemo(() => { | ||||||
|  |     // TODO: Disable certain actions based on user permissions | ||||||
|  |     return [ | ||||||
|  |       <ActionDropdown | ||||||
|  |         tooltip={t`Barcode Actions`} | ||||||
|  |         icon={<IconQrcode />} | ||||||
|  |         actions={[ | ||||||
|  |           { | ||||||
|  |             icon: <IconQrcode />, | ||||||
|  |             name: t`View`, | ||||||
|  |             tooltip: t`View part barcode` | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             icon: <IconLink />, | ||||||
|  |             name: t`Link Barcode`, | ||||||
|  |             tooltip: t`Link custom barcode to part`, | ||||||
|  |             disabled: build?.barcode_hash | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             icon: <IconUnlink />, | ||||||
|  |             name: t`Unlink Barcode`, | ||||||
|  |             tooltip: t`Unlink custom barcode from part`, | ||||||
|  |             disabled: !build?.barcode_hash | ||||||
|  |           } | ||||||
|  |         ]} | ||||||
|  |       />, | ||||||
|  |       <ActionDropdown | ||||||
|  |         tooltip={t`Reporting Actions`} | ||||||
|  |         icon={<IconPrinter />} | ||||||
|  |         actions={[ | ||||||
|  |           { | ||||||
|  |             icon: <IconFileTypePdf />, | ||||||
|  |             name: t`Report`, | ||||||
|  |             tooltip: t`Print build report` | ||||||
|  |           } | ||||||
|  |         ]} | ||||||
|  |       />, | ||||||
|  |       <ActionDropdown | ||||||
|  |         tooltip={t`Build Order Actions`} | ||||||
|  |         icon={<IconDots />} | ||||||
|  |         actions={[ | ||||||
|  |           { | ||||||
|  |             icon: <IconEdit color="blue" />, | ||||||
|  |             name: t`Edit`, | ||||||
|  |             tooltip: t`Edit build order` | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             icon: <IconCopy color="green" />, | ||||||
|  |             name: t`Duplicate`, | ||||||
|  |             tooltip: t`Duplicate build order` | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             icon: <IconTrash color="red" />, | ||||||
|  |             name: t`Delete`, | ||||||
|  |             tooltip: t`Delete build order` | ||||||
|  |           } | ||||||
|  |         ]} | ||||||
|  |       /> | ||||||
|  |     ]; | ||||||
|  |   }, [id, build, user]); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|       <Stack spacing="xs"> |       <Stack spacing="xs"> | ||||||
|         <PageDetail |         <PageDetail | ||||||
|           title={t`Build Order`} |           title={t`Build Order`} | ||||||
|           subtitle={build.reference} |           subtitle={build.reference} | ||||||
|           detail={ |  | ||||||
|             <Alert color="teal" title="Build order detail goes here"> |  | ||||||
|               <Text>TODO: Build details</Text> |  | ||||||
|             </Alert> |  | ||||||
|           } |  | ||||||
|           breadcrumbs={[ |           breadcrumbs={[ | ||||||
|             { name: t`Build Orders`, url: '/build' }, |             { name: t`Build Orders`, url: '/build' }, | ||||||
|             { name: build.reference, url: `/build/${build.pk}` } |             { name: build.reference, url: `/build/${build.pk}` } | ||||||
|           ]} |           ]} | ||||||
|           actions={[<PlaceholderPill key="1" />]} |           actions={buildActions} | ||||||
|         /> |         /> | ||||||
|         <LoadingOverlay visible={instanceQuery.isFetching} /> |         <LoadingOverlay visible={instanceQuery.isFetching} /> | ||||||
|         <PanelGroup pageKey="build" panels={buildPanels} /> |         <PanelGroup pageKey="build" panels={buildPanels} /> | ||||||
|   | |||||||
							
								
								
									
										226
									
								
								src/frontend/src/pages/company/CompanyDetail.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										226
									
								
								src/frontend/src/pages/company/CompanyDetail.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,226 @@ | |||||||
|  | import { t } from '@lingui/macro'; | ||||||
|  | import { Group, LoadingOverlay, Stack, Text } from '@mantine/core'; | ||||||
|  | import { | ||||||
|  |   IconBuildingFactory2, | ||||||
|  |   IconBuildingWarehouse, | ||||||
|  |   IconDots, | ||||||
|  |   IconEdit, | ||||||
|  |   IconInfoCircle, | ||||||
|  |   IconMap2, | ||||||
|  |   IconNotes, | ||||||
|  |   IconPackageExport, | ||||||
|  |   IconPackages, | ||||||
|  |   IconPaperclip, | ||||||
|  |   IconShoppingCart, | ||||||
|  |   IconTrash, | ||||||
|  |   IconTruckDelivery, | ||||||
|  |   IconTruckReturn, | ||||||
|  |   IconUsersGroup | ||||||
|  | } from '@tabler/icons-react'; | ||||||
|  | import { useMemo } from 'react'; | ||||||
|  | import { useParams } from 'react-router-dom'; | ||||||
|  |  | ||||||
|  | import { Thumbnail } from '../../components/images/Thumbnail'; | ||||||
|  | import { ActionDropdown } from '../../components/items/ActionDropdown'; | ||||||
|  | import { Breadcrumb } from '../../components/nav/BreadcrumbList'; | ||||||
|  | import { PageDetail } from '../../components/nav/PageDetail'; | ||||||
|  | import { PanelGroup } from '../../components/nav/PanelGroup'; | ||||||
|  | import { PanelType } from '../../components/nav/PanelGroup'; | ||||||
|  | import { AttachmentTable } from '../../components/tables/general/AttachmentTable'; | ||||||
|  | import { PurchaseOrderTable } from '../../components/tables/purchasing/PurchaseOrderTable'; | ||||||
|  | import { ReturnOrderTable } from '../../components/tables/sales/ReturnOrderTable'; | ||||||
|  | import { SalesOrderTable } from '../../components/tables/sales/SalesOrderTable'; | ||||||
|  | import { StockItemTable } from '../../components/tables/stock/StockItemTable'; | ||||||
|  | import { NotesEditor } from '../../components/widgets/MarkdownEditor'; | ||||||
|  | import { editCompany } from '../../functions/forms/CompanyForms'; | ||||||
|  | import { useInstance } from '../../hooks/UseInstance'; | ||||||
|  | import { ApiPaths, apiUrl } from '../../states/ApiState'; | ||||||
|  | import { useUserState } from '../../states/UserState'; | ||||||
|  |  | ||||||
|  | export type CompanyDetailProps = { | ||||||
|  |   title: string; | ||||||
|  |   breadcrumbs: Breadcrumb[]; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Detail view for a single company instance | ||||||
|  |  */ | ||||||
|  | export default function CompanyDetail(props: CompanyDetailProps) { | ||||||
|  |   const { id } = useParams(); | ||||||
|  |  | ||||||
|  |   const user = useUserState(); | ||||||
|  |  | ||||||
|  |   const { | ||||||
|  |     instance: company, | ||||||
|  |     refreshInstance, | ||||||
|  |     instanceQuery | ||||||
|  |   } = useInstance({ | ||||||
|  |     endpoint: ApiPaths.company_list, | ||||||
|  |     pk: id, | ||||||
|  |     params: {}, | ||||||
|  |     refetchOnMount: true | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const companyPanels: PanelType[] = useMemo(() => { | ||||||
|  |     return [ | ||||||
|  |       { | ||||||
|  |         name: 'details', | ||||||
|  |         label: t`Details`, | ||||||
|  |         icon: <IconInfoCircle /> | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         name: 'manufactured-parts', | ||||||
|  |         label: t`Manufactured Parts`, | ||||||
|  |         icon: <IconBuildingFactory2 />, | ||||||
|  |         hidden: !company?.is_manufacturer | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         name: 'supplied-parts', | ||||||
|  |         label: t`Supplied Parts`, | ||||||
|  |         icon: <IconBuildingWarehouse />, | ||||||
|  |         hidden: !company?.is_supplier | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         name: 'purchase-orders', | ||||||
|  |         label: t`Purchase Orders`, | ||||||
|  |         icon: <IconShoppingCart />, | ||||||
|  |         hidden: !company?.is_supplier, | ||||||
|  |         content: company?.pk && ( | ||||||
|  |           <PurchaseOrderTable params={{ supplier: company.pk }} /> | ||||||
|  |         ) | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         name: 'stock-items', | ||||||
|  |         label: t`Stock Items`, | ||||||
|  |         icon: <IconPackages />, | ||||||
|  |         hidden: !company?.is_manufacturer && !company?.is_supplier, | ||||||
|  |         content: company?.pk && ( | ||||||
|  |           <StockItemTable params={{ company: company.pk }} /> | ||||||
|  |         ) | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         name: 'sales-orders', | ||||||
|  |         label: t`Sales Orders`, | ||||||
|  |         icon: <IconTruckDelivery />, | ||||||
|  |         hidden: !company?.is_customer, | ||||||
|  |         content: company?.pk && ( | ||||||
|  |           <SalesOrderTable params={{ customer: company.pk }} /> | ||||||
|  |         ) | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         name: 'return-orders', | ||||||
|  |         label: t`Return Orders`, | ||||||
|  |         icon: <IconTruckReturn />, | ||||||
|  |         hidden: !company?.is_customer, | ||||||
|  |         content: company.pk && ( | ||||||
|  |           <ReturnOrderTable params={{ customer: company.pk }} /> | ||||||
|  |         ) | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         name: 'assigned-stock', | ||||||
|  |         label: t`Assigned Stock`, | ||||||
|  |         icon: <IconPackageExport />, | ||||||
|  |         hidden: !company?.is_customer | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         name: 'contacts', | ||||||
|  |         label: t`Contacts`, | ||||||
|  |         icon: <IconUsersGroup /> | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         name: 'addresses', | ||||||
|  |         label: t`Addresses`, | ||||||
|  |         icon: <IconMap2 /> | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         name: 'attachments', | ||||||
|  |         label: t`Attachments`, | ||||||
|  |         icon: <IconPaperclip />, | ||||||
|  |         content: ( | ||||||
|  |           <AttachmentTable | ||||||
|  |             endpoint={ApiPaths.company_attachment_list} | ||||||
|  |             model="company" | ||||||
|  |             pk={company.pk ?? -1} | ||||||
|  |           /> | ||||||
|  |         ) | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         name: 'notes', | ||||||
|  |         label: t`Notes`, | ||||||
|  |         icon: <IconNotes />, | ||||||
|  |         content: ( | ||||||
|  |           <NotesEditor | ||||||
|  |             url={apiUrl(ApiPaths.company_list, company.pk)} | ||||||
|  |             data={company?.notes ?? ''} | ||||||
|  |             allowEdit={true} | ||||||
|  |           /> | ||||||
|  |         ) | ||||||
|  |       } | ||||||
|  |     ]; | ||||||
|  |   }, [id, company]); | ||||||
|  |  | ||||||
|  |   const companyDetail = useMemo(() => { | ||||||
|  |     return ( | ||||||
|  |       <Group spacing="xs" noWrap={true}> | ||||||
|  |         <Thumbnail | ||||||
|  |           src={String(company.image || '')} | ||||||
|  |           size={128} | ||||||
|  |           alt={company?.name} | ||||||
|  |         /> | ||||||
|  |         <Stack spacing="xs"> | ||||||
|  |           <Text size="lg" weight={500}> | ||||||
|  |             {company.name} | ||||||
|  |           </Text> | ||||||
|  |           <Text size="sm">{company.description}</Text> | ||||||
|  |         </Stack> | ||||||
|  |       </Group> | ||||||
|  |     ); | ||||||
|  |   }, [id, company]); | ||||||
|  |  | ||||||
|  |   const companyActions = useMemo(() => { | ||||||
|  |     // TODO: Finer fidelity on these permissions, perhaps? | ||||||
|  |     let canEdit = user.checkUserRole('purchase_order', 'change'); | ||||||
|  |     let canDelete = user.checkUserRole('purchase_order', 'delete'); | ||||||
|  |  | ||||||
|  |     return [ | ||||||
|  |       <ActionDropdown | ||||||
|  |         tooltip={t`Company Actions`} | ||||||
|  |         icon={<IconDots />} | ||||||
|  |         actions={[ | ||||||
|  |           { | ||||||
|  |             icon: <IconEdit color="blue" />, | ||||||
|  |             name: t`Edit`, | ||||||
|  |             tooltip: t`Edit company`, | ||||||
|  |             disabled: !canEdit, | ||||||
|  |             onClick: () => { | ||||||
|  |               if (company?.pk) { | ||||||
|  |                 editCompany({ | ||||||
|  |                   pk: company?.pk, | ||||||
|  |                   callback: refreshInstance | ||||||
|  |                 }); | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             icon: <IconTrash color="red" />, | ||||||
|  |             name: t`Delete`, | ||||||
|  |             tooltip: t`Delete company`, | ||||||
|  |             disabled: !canDelete | ||||||
|  |           } | ||||||
|  |         ]} | ||||||
|  |       /> | ||||||
|  |     ]; | ||||||
|  |   }, [id, company, user]); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Stack spacing="xs"> | ||||||
|  |       <LoadingOverlay visible={instanceQuery.isFetching} /> | ||||||
|  |       <PageDetail | ||||||
|  |         detail={companyDetail} | ||||||
|  |         actions={companyActions} | ||||||
|  |         breadcrumbs={props.breadcrumbs} | ||||||
|  |       /> | ||||||
|  |       <PanelGroup pageKey="company" panels={companyPanels} /> | ||||||
|  |     </Stack> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										12
									
								
								src/frontend/src/pages/company/CustomerDetail.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/frontend/src/pages/company/CustomerDetail.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | import { t } from '@lingui/macro'; | ||||||
|  |  | ||||||
|  | import CompanyDetail from './CompanyDetail'; | ||||||
|  |  | ||||||
|  | export default function CustomerDetail() { | ||||||
|  |   return ( | ||||||
|  |     <CompanyDetail | ||||||
|  |       title={t`Customer`} | ||||||
|  |       breadcrumbs={[{ name: t`Sales`, url: '/sales/' }]} | ||||||
|  |     /> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										12
									
								
								src/frontend/src/pages/company/ManufacturerDetail.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/frontend/src/pages/company/ManufacturerDetail.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | import { t } from '@lingui/macro'; | ||||||
|  |  | ||||||
|  | import CompanyDetail from './CompanyDetail'; | ||||||
|  |  | ||||||
|  | export default function ManufacturerDetail() { | ||||||
|  |   return ( | ||||||
|  |     <CompanyDetail | ||||||
|  |       title={t`Manufacturer`} | ||||||
|  |       breadcrumbs={[{ name: t`Purchasing`, url: '/purchasing/' }]} | ||||||
|  |     /> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										12
									
								
								src/frontend/src/pages/company/SupplierDetail.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/frontend/src/pages/company/SupplierDetail.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | import { t } from '@lingui/macro'; | ||||||
|  |  | ||||||
|  | import CompanyDetail from './CompanyDetail'; | ||||||
|  |  | ||||||
|  | export default function SupplierDetail() { | ||||||
|  |   return ( | ||||||
|  |     <CompanyDetail | ||||||
|  |       title={t`Supplier`} | ||||||
|  |       breadcrumbs={[{ name: t`Purchasing`, url: '/purchasing/' }]} | ||||||
|  |     /> | ||||||
|  |   ); | ||||||
|  | } | ||||||
| @@ -1,34 +1,37 @@ | |||||||
| import { t } from '@lingui/macro'; | import { t } from '@lingui/macro'; | ||||||
| import { | import { Group, LoadingOverlay, Stack, Text } from '@mantine/core'; | ||||||
|   Alert, |  | ||||||
|   Button, |  | ||||||
|   Group, |  | ||||||
|   LoadingOverlay, |  | ||||||
|   Stack, |  | ||||||
|   Text |  | ||||||
| } from '@mantine/core'; |  | ||||||
| import { | import { | ||||||
|   IconBuilding, |   IconBuilding, | ||||||
|  |   IconCalendarStats, | ||||||
|  |   IconClipboardList, | ||||||
|  |   IconCopy, | ||||||
|   IconCurrencyDollar, |   IconCurrencyDollar, | ||||||
|  |   IconDots, | ||||||
|  |   IconEdit, | ||||||
|   IconInfoCircle, |   IconInfoCircle, | ||||||
|   IconLayersLinked, |   IconLayersLinked, | ||||||
|  |   IconLink, | ||||||
|   IconList, |   IconList, | ||||||
|   IconListTree, |   IconListTree, | ||||||
|   IconNotes, |   IconNotes, | ||||||
|   IconPackages, |   IconPackages, | ||||||
|   IconPaperclip, |   IconPaperclip, | ||||||
|  |   IconQrcode, | ||||||
|   IconShoppingCart, |   IconShoppingCart, | ||||||
|   IconStack2, |   IconStack2, | ||||||
|   IconTestPipe, |   IconTestPipe, | ||||||
|   IconTools, |   IconTools, | ||||||
|  |   IconTransfer, | ||||||
|  |   IconTrash, | ||||||
|   IconTruckDelivery, |   IconTruckDelivery, | ||||||
|  |   IconUnlink, | ||||||
|   IconVersions |   IconVersions | ||||||
| } from '@tabler/icons-react'; | } from '@tabler/icons-react'; | ||||||
| import { useMemo } from 'react'; | import { useMemo } from 'react'; | ||||||
| import { useParams } from 'react-router-dom'; | import { useParams } from 'react-router-dom'; | ||||||
|  |  | ||||||
| import { ApiImage } from '../../components/images/ApiImage'; | import { ApiImage } from '../../components/images/ApiImage'; | ||||||
| import { PlaceholderPanel } from '../../components/items/Placeholder'; | import { ActionDropdown } from '../../components/items/ActionDropdown'; | ||||||
| import { PageDetail } from '../../components/nav/PageDetail'; | import { PageDetail } from '../../components/nav/PageDetail'; | ||||||
| import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; | import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; | ||||||
| import { AttachmentTable } from '../../components/tables/general/AttachmentTable'; | import { AttachmentTable } from '../../components/tables/general/AttachmentTable'; | ||||||
| @@ -40,6 +43,7 @@ import { NotesEditor } from '../../components/widgets/MarkdownEditor'; | |||||||
| import { editPart } from '../../functions/forms/PartForms'; | import { editPart } from '../../functions/forms/PartForms'; | ||||||
| import { useInstance } from '../../hooks/UseInstance'; | import { useInstance } from '../../hooks/UseInstance'; | ||||||
| import { ApiPaths, apiUrl } from '../../states/ApiState'; | import { ApiPaths, apiUrl } from '../../states/ApiState'; | ||||||
|  | import { useUserState } from '../../states/UserState'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Detail view for a single Part instance |  * Detail view for a single Part instance | ||||||
| @@ -47,6 +51,8 @@ import { ApiPaths, apiUrl } from '../../states/ApiState'; | |||||||
| export default function PartDetail() { | export default function PartDetail() { | ||||||
|   const { id } = useParams(); |   const { id } = useParams(); | ||||||
|  |  | ||||||
|  |   const user = useUserState(); | ||||||
|  |  | ||||||
|   const { |   const { | ||||||
|     instance: part, |     instance: part, | ||||||
|     refreshInstance, |     refreshInstance, | ||||||
| @@ -66,8 +72,7 @@ export default function PartDetail() { | |||||||
|       { |       { | ||||||
|         name: 'details', |         name: 'details', | ||||||
|         label: t`Details`, |         label: t`Details`, | ||||||
|         icon: <IconInfoCircle />, |         icon: <IconInfoCircle /> | ||||||
|         content: <PlaceholderPanel /> |  | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         name: 'parameters', |         name: 'parameters', | ||||||
| @@ -98,55 +103,57 @@ export default function PartDetail() { | |||||||
|         name: 'bom', |         name: 'bom', | ||||||
|         label: t`Bill of Materials`, |         label: t`Bill of Materials`, | ||||||
|         icon: <IconListTree />, |         icon: <IconListTree />, | ||||||
|         hidden: !part.assembly, |         hidden: !part.assembly | ||||||
|         content: <PlaceholderPanel /> |  | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         name: 'builds', |         name: 'builds', | ||||||
|         label: t`Build Orders`, |         label: t`Build Orders`, | ||||||
|         icon: <IconTools />, |         icon: <IconTools />, | ||||||
|         hidden: !part.assembly && !part.component, |         hidden: !part.assembly && !part.component | ||||||
|         content: <PlaceholderPanel /> |  | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         name: 'used_in', |         name: 'used_in', | ||||||
|         label: t`Used In`, |         label: t`Used In`, | ||||||
|         icon: <IconStack2 />, |         icon: <IconStack2 />, | ||||||
|         hidden: !part.component, |         hidden: !part.component | ||||||
|         content: <PlaceholderPanel /> |  | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         name: 'pricing', |         name: 'pricing', | ||||||
|         label: t`Pricing`, |         label: t`Pricing`, | ||||||
|         icon: <IconCurrencyDollar />, |         icon: <IconCurrencyDollar /> | ||||||
|         content: <PlaceholderPanel /> |  | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         name: 'suppliers', |         name: 'suppliers', | ||||||
|         label: t`Suppliers`, |         label: t`Suppliers`, | ||||||
|         icon: <IconBuilding />, |         icon: <IconBuilding />, | ||||||
|         hidden: !part.purchaseable, |         hidden: !part.purchaseable | ||||||
|         content: <PlaceholderPanel /> |  | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         name: 'purchase_orders', |         name: 'purchase_orders', | ||||||
|         label: t`Purchase Orders`, |         label: t`Purchase Orders`, | ||||||
|         icon: <IconShoppingCart />, |         icon: <IconShoppingCart />, | ||||||
|         content: <PlaceholderPanel />, |  | ||||||
|         hidden: !part.purchaseable |         hidden: !part.purchaseable | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         name: 'sales_orders', |         name: 'sales_orders', | ||||||
|         label: t`Sales Orders`, |         label: t`Sales Orders`, | ||||||
|         icon: <IconTruckDelivery />, |         icon: <IconTruckDelivery />, | ||||||
|         content: <PlaceholderPanel />, |  | ||||||
|         hidden: !part.salable |         hidden: !part.salable | ||||||
|       }, |       }, | ||||||
|  |       { | ||||||
|  |         name: 'scheduling', | ||||||
|  |         label: t`Scheduling`, | ||||||
|  |         icon: <IconCalendarStats /> | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         name: 'stocktake', | ||||||
|  |         label: t`Stocktake`, | ||||||
|  |         icon: <IconClipboardList /> | ||||||
|  |       }, | ||||||
|       { |       { | ||||||
|         name: 'test_templates', |         name: 'test_templates', | ||||||
|         label: t`Test Templates`, |         label: t`Test Templates`, | ||||||
|         icon: <IconTestPipe />, |         icon: <IconTestPipe />, | ||||||
|         content: <PlaceholderPanel />, |  | ||||||
|         hidden: !part.trackable |         hidden: !part.trackable | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
| @@ -212,6 +219,79 @@ export default function PartDetail() { | |||||||
|     ); |     ); | ||||||
|   }, [part, id]); |   }, [part, id]); | ||||||
|  |  | ||||||
|  |   const partActions = useMemo(() => { | ||||||
|  |     // TODO: Disable actions based on user permissions | ||||||
|  |     return [ | ||||||
|  |       <ActionDropdown | ||||||
|  |         tooltip={t`Barcode Actions`} | ||||||
|  |         icon={<IconQrcode />} | ||||||
|  |         actions={[ | ||||||
|  |           { | ||||||
|  |             icon: <IconQrcode />, | ||||||
|  |             name: t`View`, | ||||||
|  |             tooltip: t`View part barcode` | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             icon: <IconLink />, | ||||||
|  |             name: t`Link Barcode`, | ||||||
|  |             tooltip: t`Link custom barcode to part`, | ||||||
|  |             disabled: part?.barcode_hash | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             icon: <IconUnlink />, | ||||||
|  |             name: t`Unlink Barcode`, | ||||||
|  |             tooltip: t`Unlink custom barcode from part`, | ||||||
|  |             disabled: !part?.barcode_hash | ||||||
|  |           } | ||||||
|  |         ]} | ||||||
|  |       />, | ||||||
|  |       <ActionDropdown | ||||||
|  |         tooltip={t`Stock Actions`} | ||||||
|  |         icon={<IconPackages />} | ||||||
|  |         actions={[ | ||||||
|  |           { | ||||||
|  |             icon: <IconClipboardList color="blue" />, | ||||||
|  |             name: t`Count Stock`, | ||||||
|  |             tooltip: t`Count part stock` | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             icon: <IconTransfer color="blue" />, | ||||||
|  |             name: t`Transfer Stock`, | ||||||
|  |             tooltip: t`Transfer part stock` | ||||||
|  |           } | ||||||
|  |         ]} | ||||||
|  |       />, | ||||||
|  |       <ActionDropdown | ||||||
|  |         tooltip={t`Part Actions`} | ||||||
|  |         icon={<IconDots />} | ||||||
|  |         actions={[ | ||||||
|  |           { | ||||||
|  |             icon: <IconEdit color="blue" />, | ||||||
|  |             name: t`Edit`, | ||||||
|  |             tooltip: t`Edit part`, | ||||||
|  |             onClick: () => { | ||||||
|  |               part.pk && | ||||||
|  |                 editPart({ | ||||||
|  |                   part_id: part.pk, | ||||||
|  |                   callback: refreshInstance | ||||||
|  |                 }); | ||||||
|  |             } | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             icon: <IconCopy color="green" />, | ||||||
|  |             name: t`Duplicate`, | ||||||
|  |             tooltip: t`Duplicate part` | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             icon: <IconTrash color="red" />, | ||||||
|  |             name: t`Delete`, | ||||||
|  |             tooltip: t`Delete part` | ||||||
|  |           } | ||||||
|  |         ]} | ||||||
|  |       /> | ||||||
|  |     ]; | ||||||
|  |   }, [id, part, user]); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|       <Stack spacing="xs"> |       <Stack spacing="xs"> | ||||||
| @@ -219,21 +299,7 @@ export default function PartDetail() { | |||||||
|         <PageDetail |         <PageDetail | ||||||
|           detail={partDetail} |           detail={partDetail} | ||||||
|           breadcrumbs={breadcrumbs} |           breadcrumbs={breadcrumbs} | ||||||
|           actions={[ |           actions={partActions} | ||||||
|             <Button |  | ||||||
|               variant="outline" |  | ||||||
|               color="blue" |  | ||||||
|               onClick={() => |  | ||||||
|                 part.pk && |  | ||||||
|                 editPart({ |  | ||||||
|                   part_id: part.pk, |  | ||||||
|                   callback: refreshInstance |  | ||||||
|                 }) |  | ||||||
|               } |  | ||||||
|             > |  | ||||||
|               Edit Part |  | ||||||
|             </Button> |  | ||||||
|           ]} |  | ||||||
|         /> |         /> | ||||||
|         <PanelGroup pageKey="part" panels={partPanels} /> |         <PanelGroup pageKey="part" panels={partPanels} /> | ||||||
|       </Stack> |       </Stack> | ||||||
|   | |||||||
| @@ -26,13 +26,23 @@ export default function PurchasingIndex() { | |||||||
|         name: 'suppliers', |         name: 'suppliers', | ||||||
|         label: t`Suppliers`, |         label: t`Suppliers`, | ||||||
|         icon: <IconBuildingStore />, |         icon: <IconBuildingStore />, | ||||||
|         content: <CompanyTable params={{ is_supplier: true }} /> |         content: ( | ||||||
|  |           <CompanyTable | ||||||
|  |             path="purchasing/supplier" | ||||||
|  |             params={{ is_supplier: true }} | ||||||
|  |           /> | ||||||
|  |         ) | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         name: 'manufacturer', |         name: 'manufacturer', | ||||||
|         label: t`Manufacturers`, |         label: t`Manufacturers`, | ||||||
|         icon: <IconBuildingFactory2 />, |         icon: <IconBuildingFactory2 />, | ||||||
|         content: <CompanyTable params={{ is_manufacturer: true }} /> |         content: ( | ||||||
|  |           <CompanyTable | ||||||
|  |             path="purchasing/manufacturer" | ||||||
|  |             params={{ is_manufacturer: true }} | ||||||
|  |           /> | ||||||
|  |         ) | ||||||
|       } |       } | ||||||
|     ]; |     ]; | ||||||
|   }, []); |   }, []); | ||||||
|   | |||||||
| @@ -32,7 +32,9 @@ export default function PurchasingIndex() { | |||||||
|         name: 'suppliers', |         name: 'suppliers', | ||||||
|         label: t`Customers`, |         label: t`Customers`, | ||||||
|         icon: <IconBuildingStore />, |         icon: <IconBuildingStore />, | ||||||
|         content: <CompanyTable params={{ is_customer: true }} /> |         content: ( | ||||||
|  |           <CompanyTable path="sales/customer" params={{ is_customer: true }} /> | ||||||
|  |         ) | ||||||
|       } |       } | ||||||
|     ]; |     ]; | ||||||
|   }, []); |   }, []); | ||||||
|   | |||||||
| @@ -12,6 +12,22 @@ export const Playground = Loadable( | |||||||
|   lazy(() => import('./pages/Index/Playground')) |   lazy(() => import('./pages/Index/Playground')) | ||||||
| ); | ); | ||||||
|  |  | ||||||
|  | export const CompanyDetail = Loadable( | ||||||
|  |   lazy(() => import('./pages/company/CompanyDetail')) | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | export const CustomerDetail = Loadable( | ||||||
|  |   lazy(() => import('./pages/company/CustomerDetail')) | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | export const SupplierDetail = Loadable( | ||||||
|  |   lazy(() => import('./pages/company/SupplierDetail')) | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | export const ManufacturerDetail = Loadable( | ||||||
|  |   lazy(() => import('./pages/company/ManufacturerDetail')) | ||||||
|  | ); | ||||||
|  |  | ||||||
| export const CategoryDetail = Loadable( | export const CategoryDetail = Loadable( | ||||||
|   lazy(() => import('./pages/part/CategoryDetail')) |   lazy(() => import('./pages/part/CategoryDetail')) | ||||||
| ); | ); | ||||||
| @@ -109,9 +125,13 @@ export const routes = ( | |||||||
|       </Route> |       </Route> | ||||||
|       <Route path="purchasing/"> |       <Route path="purchasing/"> | ||||||
|         <Route index element={<PurchasingIndex />} /> |         <Route index element={<PurchasingIndex />} /> | ||||||
|  |         <Route path="supplier/:id/" element={<SupplierDetail />} /> | ||||||
|  |         <Route path="manufacturer/:id/" element={<ManufacturerDetail />} /> | ||||||
|       </Route> |       </Route> | ||||||
|  |       <Route path="company/:id/" element={<CompanyDetail />} /> | ||||||
|       <Route path="sales/"> |       <Route path="sales/"> | ||||||
|         <Route index element={<SalesIndex />} /> |         <Route index element={<SalesIndex />} /> | ||||||
|  |         <Route path="customer/:id/" element={<CustomerDetail />} /> | ||||||
|       </Route> |       </Route> | ||||||
|       <Route path="/profile/:tabValue" element={<Profile />} /> |       <Route path="/profile/:tabValue" element={<Profile />} /> | ||||||
|     </Route> |     </Route> | ||||||
|   | |||||||
| @@ -55,6 +55,7 @@ export enum ApiPaths { | |||||||
|  |  | ||||||
|   // Company URLs |   // Company URLs | ||||||
|   company_list = 'api-company-list', |   company_list = 'api-company-list', | ||||||
|  |   company_attachment_list = 'api-company-attachment-list', | ||||||
|   supplier_part_list = 'api-supplier-part-list', |   supplier_part_list = 'api-supplier-part-list', | ||||||
|  |  | ||||||
|   // Stock Item URLs |   // Stock Item URLs | ||||||
| @@ -137,6 +138,8 @@ export function apiEndpoint(path: ApiPaths): string { | |||||||
|       return 'part/attachment/'; |       return 'part/attachment/'; | ||||||
|     case ApiPaths.company_list: |     case ApiPaths.company_list: | ||||||
|       return 'company/'; |       return 'company/'; | ||||||
|  |     case ApiPaths.company_attachment_list: | ||||||
|  |       return 'company/attachment/'; | ||||||
|     case ApiPaths.supplier_part_list: |     case ApiPaths.supplier_part_list: | ||||||
|       return 'company/part/'; |       return 'company/part/'; | ||||||
|     case ApiPaths.stock_item_list: |     case ApiPaths.stock_item_list: | ||||||
|   | |||||||
							
								
								
									
										16
									
								
								src/frontend/src/states/ModalState.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/frontend/src/states/ModalState.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | import { create } from 'zustand'; | ||||||
|  |  | ||||||
|  | interface ModalStateProps { | ||||||
|  |   loading: boolean; | ||||||
|  |   lock: () => void; | ||||||
|  |   unlock: () => void; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Global state manager for modal forms. | ||||||
|  |  */ | ||||||
|  | export const useModalState = create<ModalStateProps>((set) => ({ | ||||||
|  |   loading: false, | ||||||
|  |   lock: () => set(() => ({ loading: true })), | ||||||
|  |   unlock: () => set(() => ({ loading: false })) | ||||||
|  | })); | ||||||
| @@ -10,6 +10,7 @@ interface UserStateProps { | |||||||
|   username: () => string; |   username: () => string; | ||||||
|   setUser: (newUser: UserProps) => void; |   setUser: (newUser: UserProps) => void; | ||||||
|   fetchUserState: () => void; |   fetchUserState: () => void; | ||||||
|  |   checkUserRole: (role: string, permission: string) => boolean; | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user