diff --git a/docs/docs/plugins/mixins/supplier.md b/docs/docs/plugins/mixins/supplier.md
new file mode 100644
index 000000000000..de92b767dd78
--- /dev/null
+++ b/docs/docs/plugins/mixins/supplier.md
@@ -0,0 +1,47 @@
+---
+title: Supplier Mixin
+---
+
+## SupplierMixin
+
+The `SupplierMixin` class enables plugins to integrate with external suppliers, enabling seamless creation of parts, supplier parts, and manufacturer parts with just a few clicks from the supplier. The import process is split into multiple phases:
+
+- Search supplier
+- Select InvenTree category
+- Match Part Parameters
+- Create initial Stock
+
+### Import Methods
+
+When a user initiates a search through the UI, the `get_search_results` function is called, and the search results are returned. These contain a `part_id` which is then passed to `get_import_data` if a user decides to import that specific part. This function should return a bunch of data that is needed for the import process. This data may be cached in the future for the same `part_id`. Then depending if the user only wants to import the supplier and manufacturer part or the whole part, the `import_part`, `import_manufacturer_part` and `import_supplier_part` methods are called automatically. If the user has imported the complete part, the `get_parameters` method is used to get a list of parameters which then can be match to inventree part parameter templates with some provided guidance. Additionally the `get_pricing_data` method is used to extract price breaks which are automatically considered when creating initial stock through the UI in the part import wizard.
+
+For that to work, a few methods need to be overridden:
+
+::: plugin.base.supplier.mixins.SupplierMixin
+ options:
+ show_bases: False
+ show_root_heading: False
+ show_root_toc_entry: False
+ summary: False
+ members:
+ - get_search_results
+ - get_import_data
+ - get_pricing_data
+ - get_parameters
+ - import_part
+ - import_manufacturer_part
+ - import_supplier_part
+ extra:
+ show_sources: True
+
+### Sample Plugin
+
+A simple example is provided in the InvenTree code base. Note that this uses some static data, but this can be extended in a real world plugin to e.g. call the supplier's API:
+
+::: plugin.samples.supplier.supplier_sample.SampleSupplierPlugin
+ options:
+ show_bases: False
+ show_root_heading: False
+ show_root_toc_entry: False
+ show_source: True
+ members: []
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index 712dce5cf2e1..86e3da22424e 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -228,6 +228,7 @@ nav:
- Report Mixin: plugins/mixins/report.md
- Schedule Mixin: plugins/mixins/schedule.md
- Settings Mixin: plugins/mixins/settings.md
+ - Supplier Mixin: plugins/mixins/supplier.md
- URL Mixin: plugins/mixins/urls.md
- User Interface Mixin: plugins/mixins/ui.md
- Validation Mixin: plugins/mixins/validation.md
diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index 0dea53508bf3..5d7d478807f3 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -1,12 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
-INVENTREE_API_VERSION = 348
+INVENTREE_API_VERSION = 349
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
+v349 -> 2025-06-12 : https://github.com/inventree/InvenTree/pull/9761
+ - Add supplier search and import API endpoints
+ - Add part parameter bulk create API endpoint
+
v348 -> 2025-04-22 : https://github.com/inventree/InvenTree/pull/9312
- Adds "external" flag for BuildOrder
- Adds link between PurchaseOrderLineItem and BuildOrder
diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py
index 9aa653c1af58..bd4e5ff1dd2c 100644
--- a/src/backend/InvenTree/InvenTree/serializers.py
+++ b/src/backend/InvenTree/InvenTree/serializers.py
@@ -529,3 +529,52 @@ def validate_remote_image(self, url):
raise ValidationError(_('Failed to download image from remote URL'))
return url
+
+
+class ListUniqueValidator:
+ """List validator that validates unique fields for bulk create.
+
+ See: https://github.com/encode/django-rest-framework/issues/6395#issuecomment-452412653
+ """
+
+ message = 'This field must be unique.'
+
+ def __init__(self, unique_field_names):
+ """Initialize the validator with a list of unique field names."""
+ self.unique_field_names = unique_field_names
+
+ @staticmethod
+ def has_duplicates(counter):
+ """Check if there are any duplicate values in the counter."""
+ return any(count for count in counter.values() if count > 1)
+
+ def __call__(self, value):
+ """Validate that the specified fields are unique across the list of items."""
+ from collections import Counter
+
+ field_counters = {
+ field_name: Counter(
+ item[field_name] for item in value if field_name in item
+ )
+ for field_name in self.unique_field_names
+ }
+ has_duplicates = any(
+ ListUniqueValidator.has_duplicates(counter)
+ for counter in field_counters.values()
+ )
+ if has_duplicates:
+ errors = []
+ for item in value:
+ error = {}
+ for field_name in self.unique_field_names:
+ counter = field_counters[field_name]
+ if counter[item.get(field_name)] > 1:
+ error[field_name] = self.message
+ errors.append(error)
+ raise ValidationError(errors)
+
+ def __repr__(self):
+ """Return a string representation of the validator."""
+ return (
+ f'<{self.__class__.__name__}(unique_field_names={self.unique_field_names})>'
+ )
diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py
index d86c3c17f478..c29e91828fd8 100644
--- a/src/backend/InvenTree/part/api.py
+++ b/src/backend/InvenTree/part/api.py
@@ -14,6 +14,7 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
+from rest_framework.generics import CreateAPIView
from rest_framework.response import Response
import InvenTree.permissions
@@ -1700,6 +1701,23 @@ class PartParameterList(PartParameterAPIMixin, DataExportViewMixin, ListCreateAP
]
+class PartParameterBulkCreate(CreateAPIView):
+ """Bulk create part parameters.
+
+ - POST: Bulk create part parameters
+ """
+
+ serializer_class = part_serializers.PartParameterBulkSerializer
+ queryset = PartParameter.objects.all()
+
+ def get_serializer(self, *args, **kwargs):
+ """Return the serializer instance for this API endpoint."""
+ if isinstance(kwargs.get('data', {}), list):
+ kwargs['many'] = True
+
+ return super().get_serializer(*args, **kwargs)
+
+
class PartParameterDetail(PartParameterAPIMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a single PartParameter object."""
@@ -2182,6 +2200,11 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI):
),
]),
),
+ path(
+ 'bulk/',
+ PartParameterBulkCreate.as_view(),
+ name='api-part-parameter-bulk-create',
+ ),
path('', PartParameterList.as_view(), name='api-part-parameter-list'),
]),
),
diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py
index 875d2f64c355..12872444a21e 100644
--- a/src/backend/InvenTree/part/serializers.py
+++ b/src/backend/InvenTree/part/serializers.py
@@ -472,6 +472,29 @@ def __init__(self, *args, **kwargs):
)
+class TemplateUniquenessListSerializer(serializers.ListSerializer):
+ """List serializer that validates unique fields for bulk create."""
+
+ validators = [
+ InvenTree.serializers.ListUniqueValidator(unique_field_names=['template'])
+ ]
+
+
+class PartParameterBulkSerializer(InvenTree.serializers.InvenTreeModelSerializer):
+ """JSON serializers for the PartParameter model."""
+
+ class Meta:
+ """Metaclass defining serializer fields."""
+
+ model = PartParameter
+ fields = ['pk', 'part', 'template', 'data', 'data_numeric']
+ list_serializer_class = TemplateUniquenessListSerializer
+
+ def __init__(self, *args, **kwargs):
+ """Custom initialization method for the serializer."""
+ serializers.ModelSerializer.__init__(self, *args, **kwargs)
+
+
class DuplicatePartSerializer(serializers.Serializer):
"""Serializer for specifying options when duplicating a Part.
diff --git a/src/backend/InvenTree/plugin/api.py b/src/backend/InvenTree/plugin/api.py
index dacd6e2ac8c9..f016b59ac5c5 100644
--- a/src/backend/InvenTree/plugin/api.py
+++ b/src/backend/InvenTree/plugin/api.py
@@ -30,6 +30,7 @@
from plugin.base.action.api import ActionPluginView
from plugin.base.barcodes.api import barcode_api_urls
from plugin.base.locate.api import LocatePluginView
+from plugin.base.supplier.api import supplier_api_urls
from plugin.base.ui.api import ui_plugins_api_urls
from plugin.models import PluginConfig, PluginSetting
from plugin.plugin import InvenTreePlugin
@@ -525,4 +526,5 @@ class PluginMetadataView(MetadataView):
path('', PluginList.as_view(), name='api-plugin-list'),
]),
),
+ path('supplier/', include(supplier_api_urls)),
]
diff --git a/src/backend/InvenTree/plugin/base/supplier/__init__.py b/src/backend/InvenTree/plugin/base/supplier/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/src/backend/InvenTree/plugin/base/supplier/api.py b/src/backend/InvenTree/plugin/base/supplier/api.py
new file mode 100644
index 000000000000..c89b9b702ad4
--- /dev/null
+++ b/src/backend/InvenTree/plugin/base/supplier/api.py
@@ -0,0 +1,200 @@
+"""API views for supplier plugins in InvenTree."""
+
+from django.db import transaction
+from django.urls import path
+
+from drf_spectacular.utils import OpenApiParameter, extend_schema
+from rest_framework import status
+from rest_framework.exceptions import NotFound
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+from InvenTree import permissions
+from part.models import PartCategoryParameterTemplate
+from plugin import registry
+from plugin.plugin import PluginMixinEnum
+
+from .serializers import (
+ ImportRequestSerializer,
+ ImportResultSerializer,
+ SearchResultSerializer,
+)
+
+# from .supplier import ImportParameter, PartNotFoundError
+
+
+class SearchPart(APIView):
+ """Search parts by supplier.
+
+ - GET: Start part search
+ """
+
+ role_required = 'part.add'
+ permission_classes = [
+ permissions.IsAuthenticatedOrReadScope,
+ permissions.RolePermission,
+ ]
+ serializer_class = SearchResultSerializer
+
+ @extend_schema(
+ parameters=[
+ OpenApiParameter(
+ name='supplier', description='Supplier slug', required=True
+ ),
+ OpenApiParameter(name='term', description='Search term', required=True),
+ ],
+ responses={200: SearchResultSerializer(many=True)},
+ )
+ def get(self, request):
+ """Search parts by supplier."""
+ supplier_slug = request.query_params.get('supplier', '')
+
+ supplier = None
+ for plugin in registry.with_mixin(PluginMixinEnum.SUPPLIER):
+ if plugin.slug == supplier_slug:
+ supplier = plugin
+ break
+
+ if not supplier:
+ raise NotFound(detail=f"Supplier '{supplier_slug}' not found")
+
+ term = request.query_params.get('term', '')
+ try:
+ results = supplier.get_search_results(term)
+ except Exception as e:
+ return Response(
+ {'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
+ )
+
+ response = SearchResultSerializer(results, many=True).data
+ return Response(response)
+
+
+class ImportPart(APIView):
+ """Import a part by supplier.
+
+ - POST: Attempt to import part by sku
+ """
+
+ role_required = 'part.add'
+ permission_classes = [
+ permissions.IsAuthenticatedOrReadScope,
+ permissions.RolePermission,
+ ]
+ serializer_class = ImportResultSerializer
+
+ @extend_schema(
+ request=ImportRequestSerializer, responses={200: ImportResultSerializer}
+ )
+ def post(self, request):
+ """Import a part by supplier."""
+ serializer = ImportRequestSerializer(data=request.data)
+ if not serializer.is_valid():
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+ # Extract validated data
+ supplier_slug = serializer.validated_data.get('supplier', '')
+ part_import_id = serializer.validated_data.get('part_import_id', None)
+ category = serializer.validated_data.get('category_id', None)
+ part = serializer.validated_data.get('part_id', None)
+
+ # Find the supplier plugin
+ supplier = None
+ for plugin in registry.with_mixin(PluginMixinEnum.SUPPLIER):
+ if plugin.slug == supplier_slug:
+ supplier = plugin
+ break
+
+ # Validate supplier and part/category
+ if not supplier:
+ raise NotFound(detail=f"Supplier '{supplier_slug}' not found")
+ if not part and not category:
+ return Response(
+ {
+ 'detail': "'category_id' is not provided, but required if no part_id is provided"
+ },
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ from plugin.base.supplier.mixins import SupplierMixin
+
+ # Import part data
+ try:
+ import_data = supplier.get_import_data(part_import_id)
+
+ with transaction.atomic():
+ # create part if it does not exist
+ if not part:
+ part = supplier.import_part(
+ import_data, category=category, creation_user=request.user
+ )
+
+ # create manufacturer part
+ manufacturer_part = supplier.import_manufacturer_part(
+ import_data, part=part
+ )
+
+ # create supplier part
+ supplier_part = supplier.import_supplier_part(
+ import_data, part=part, manufacturer_part=manufacturer_part
+ )
+
+ # set default supplier if not set
+ if not part.default_supplier:
+ part.default_supplier = supplier_part
+ part.save()
+
+ # get pricing
+ pricing = supplier.get_pricing_data(import_data)
+
+ # get parameters
+ parameters = supplier.get_parameters(import_data)
+ except SupplierMixin.PartNotFoundError:
+ return Response(
+ {'detail': f"Part with id: '{part_import_id}' not found"},
+ status=status.HTTP_404_NOT_FOUND,
+ )
+ except Exception as e:
+ return Response(
+ {'detail': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
+ )
+
+ # add default parameters for category
+ if category:
+ categories = category.get_ancestors(include_self=True)
+ category_parameters = PartCategoryParameterTemplate.objects.filter(
+ category__in=categories
+ )
+
+ for c in category_parameters:
+ for p in parameters:
+ if p.parameter_template == c.parameter_template:
+ p.on_category = True
+ p.value = p.value if p.value is not None else c.default_value
+ break
+ else:
+ parameters.append(
+ SupplierMixin.ImportParameter(
+ name=c.parameter_template.name,
+ value=c.default_value,
+ on_category=True,
+ parameter_template=c.parameter_template,
+ )
+ )
+ parameters.sort(key=lambda x: x.on_category, reverse=True)
+
+ response = ImportResultSerializer({
+ 'part_id': part.pk,
+ 'part_detail': part,
+ 'supplier_part_id': supplier_part.pk,
+ 'manufacturer_part_id': manufacturer_part.pk,
+ 'pricing': pricing,
+ 'parameters': parameters,
+ }).data
+ return Response(response)
+
+
+supplier_api_urls = [
+ path('search/', SearchPart.as_view(), name='api-supplier-search'),
+ path('import/', ImportPart.as_view(), name='api-supplier-import'),
+]
diff --git a/src/backend/InvenTree/plugin/base/supplier/mixins.py b/src/backend/InvenTree/plugin/base/supplier/mixins.py
new file mode 100644
index 000000000000..e664bb2606e9
--- /dev/null
+++ b/src/backend/InvenTree/plugin/base/supplier/mixins.py
@@ -0,0 +1,217 @@
+"""Plugin mixin class for Supplier Integration."""
+
+import io
+from dataclasses import dataclass
+from typing import Any, Generic, Optional, TypeVar
+
+import django.contrib.auth.models
+from django.core.exceptions import ValidationError
+from django.core.files.base import ContentFile
+
+import company.models
+import part.models as part_models
+from InvenTree.helpers_model import download_image_from_url
+from plugin import PluginMixinEnum
+from plugin.mixins import SettingsMixin
+
+PartData = TypeVar('PartData')
+
+
+class SupplierMixin(SettingsMixin, Generic[PartData]):
+ """Mixin which provides integration to specific suppliers."""
+
+ SUPPLIER_NAME: str
+
+ class MixinMeta:
+ """Meta options for this mixin."""
+
+ MIXIN_NAME = 'Supplier'
+
+ def __init__(self):
+ """Register mixin."""
+ super().__init__()
+ self.add_mixin(PluginMixinEnum.SUPPLIER, True, __class__)
+
+ self.SETTINGS['SUPPLIER'] = {
+ 'name': 'Supplier',
+ 'description': 'The Supplier which this plugin integrates with.',
+ 'model': 'company.company',
+ 'model_filters': {'is_supplier': True},
+ 'required': True,
+ }
+
+ @property
+ def supplier(self):
+ """Return the supplier company object."""
+ return company.models.Company.objects.get(
+ pk=self.get_setting('SUPPLIER', cache=True)
+ )
+
+ @dataclass
+ class SearchResult:
+ """Data class to represent a search result from a supplier."""
+
+ sku: str
+ name: str
+ exact: bool
+ description: Optional[str] = None
+ price: Optional[str] = None
+ link: Optional[str] = None
+ image_url: Optional[str] = None
+ id: Optional[str] = None
+ existing_part: Optional[part_models.Part] = None
+
+ def __post_init__(self):
+ """Post-initialization to set the ID if not provided."""
+ if not self.id:
+ self.id = self.sku
+
+ @dataclass
+ class ImportParameter:
+ """Data class to represent a parameter for a part during import."""
+
+ name: str
+ value: str
+ on_category: Optional[bool] = False
+ parameter_template: Optional[part_models.PartParameterTemplate] = None
+
+ def __post_init__(self):
+ """Post-initialization to fetch the parameter template if not provided."""
+ if not self.parameter_template:
+ try:
+ self.parameter_template = (
+ part_models.PartParameterTemplate.objects.get(
+ name__iexact=self.name
+ )
+ )
+ except part_models.PartParameterTemplate.DoesNotExist:
+ pass
+
+ class PartNotFoundError(Exception):
+ """Exception raised when a part is not found during import."""
+
+ class PartImportError(Exception):
+ """Exception raised when an error occurs during part import."""
+
+ # --- Methods to be overridden by plugins ---
+ def get_search_results(self, term: str) -> list[SearchResult]:
+ """Return a list of search results for the given search term."""
+ raise NotImplementedError('This method needs to be overridden.')
+
+ def get_import_data(self, part_id: str) -> PartData:
+ """Return the import data for the given part ID."""
+ raise NotImplementedError('This method needs to be overridden.')
+
+ def get_pricing_data(self, data: PartData) -> dict[int, tuple[float, str]]:
+ """Return a dictionary of pricing data for the given part data."""
+ raise NotImplementedError('This method needs to be overridden.')
+
+ def get_parameters(self, data: PartData) -> list[ImportParameter]:
+ """Return a list of parameters for the given part data."""
+ raise NotImplementedError('This method needs to be overridden.')
+
+ def import_part(
+ self,
+ data: PartData,
+ *,
+ category: Optional[part_models.PartCategory],
+ creation_user: Optional[django.contrib.auth.models.User],
+ ) -> part_models.Part:
+ """Import a part using the provided data.
+
+ This may include:
+ - Creating a new part
+ - Add an image to the part
+ - if this part has several variants, (create) a template part and assign it to the part
+ - create related parts
+ - add attachments to the part
+ """
+ raise NotImplementedError('This method needs to be overridden.')
+
+ def import_manufacturer_part(
+ self, data: PartData, *, part: part_models.Part
+ ) -> company.models.ManufacturerPart:
+ """Import a manufacturer part using the provided data.
+
+ This may include:
+ - Creating a new manufacturer
+ - Creating a new manufacturer part
+ - Assigning the part to the manufacturer part
+ - Setting the default supplier for the part
+ - Adding parameters to the manufacturer part
+ - Adding attachments to the manufacturer part
+ """
+ raise NotImplementedError('This method needs to be overridden.')
+
+ def import_supplier_part(
+ self,
+ data: PartData,
+ *,
+ part: part_models.Part,
+ manufacturer_part: company.models.ManufacturerPart,
+ ) -> part_models.SupplierPart:
+ """Import a supplier part using the provided data.
+
+ This may include:
+ - Creating a new supplier part
+ - Creating supplier price breaks
+ """
+ raise NotImplementedError('This method needs to be overridden.')
+
+ # --- Helper methods for importing parts ---
+ def download_image(self, img_url: str):
+ """Download an image from the given URL and return it as a ContentFile."""
+ img_r = download_image_from_url(img_url)
+ fmt = img_r.format or 'PNG'
+ buffer = io.BytesIO()
+ img_r.save(buffer, format=fmt)
+
+ return ContentFile(buffer.getvalue()), fmt
+
+ def get_template_part(
+ self, other_variants: list[part_models.Part], template_kwargs: dict[str, Any]
+ ) -> part_models.Part:
+ """Helper function to handle variant parts.
+
+ This helper function identifies all roots for the provided 'other_variants' list
+ - for no root => root part will be created using the 'template_kwargs'
+ - for one root
+ - root is a template => return it
+ - root is no template, create a new template like if there is no root
+ and assign it to only root that was found and return it
+ - for multiple roots => error raised
+ """
+ root_set = {v.get_root() for v in other_variants}
+
+ # check how much roots for the variant parts exist to identify the parent_part
+ parent_part = None # part that should be used as parent_part
+ root_part = None # part that was discovered as root part in root_set
+ if len(root_set) == 1:
+ root_part = next(iter(root_set))
+ if root_part.is_template:
+ parent_part = root_part
+
+ if len(root_set) == 0 or (root_part and not root_part.is_template):
+ parent_part = part_models.Part.objects.create(**template_kwargs)
+
+ if not parent_part:
+ raise SupplierMixin.PartImportError(
+ f'A few variant parts from the supplier are already imported, but have different InvenTree variant root parts, try to merge them to the same root variant template part (parts: {", ".join(str(p.pk) for p in other_variants)}).'
+ )
+
+ # assign parent_part to root_part if root_part has no variant of already
+ if root_part and not root_part.is_template and not root_part.variant_of:
+ root_part.variant_of = parent_part
+ root_part.save()
+
+ return parent_part
+
+ def create_related_parts(
+ self, part: part_models.Part, related_parts: list[part_models.Part]
+ ):
+ """Create relationships between the given part and related parts."""
+ for p in related_parts:
+ try:
+ part_models.PartRelated.objects.create(part_1=part, part_2=p)
+ except ValidationError:
+ pass # pass, duplicate relationship detected
diff --git a/src/backend/InvenTree/plugin/base/supplier/serializers.py b/src/backend/InvenTree/plugin/base/supplier/serializers.py
new file mode 100644
index 000000000000..ad7200916104
--- /dev/null
+++ b/src/backend/InvenTree/plugin/base/supplier/serializers.py
@@ -0,0 +1,106 @@
+"""Serializer definitions for the supplier plugin base."""
+
+from typing import Any, Optional
+
+from rest_framework import serializers
+
+import part.models as part_models
+from part.serializers import PartSerializer
+
+
+class SearchResultSerializer(serializers.Serializer):
+ """Serializer for a search result."""
+
+ class Meta:
+ """Meta options for the SearchResultSerializer."""
+
+ fields = [
+ 'id',
+ 'sku',
+ 'name',
+ 'exact',
+ 'description',
+ 'price',
+ 'link',
+ 'image_url',
+ 'existing_part_id',
+ ]
+ read_only_fields = fields
+
+ id = serializers.CharField()
+ sku = serializers.CharField()
+ name = serializers.CharField()
+ exact = serializers.BooleanField()
+ description = serializers.CharField()
+ price = serializers.CharField()
+ link = serializers.CharField()
+ image_url = serializers.CharField()
+ existing_part_id = serializers.SerializerMethodField()
+
+ def get_existing_part_id(self, value) -> Optional[int]:
+ """Return the ID of the existing part if available."""
+ return getattr(value.existing_part, 'pk', None)
+
+
+class ImportParameterSerializer(serializers.Serializer):
+ """Serializer for a ImportParameter."""
+
+ class Meta:
+ """Meta options for the ImportParameterSerializer."""
+
+ fields = ['name', 'value', 'parameter_template', 'on_category']
+
+ name = serializers.CharField()
+ value = serializers.CharField()
+ parameter_template = serializers.SerializerMethodField()
+ on_category = serializers.BooleanField()
+
+ def get_parameter_template(self, value) -> Optional[int]:
+ """Return the ID of the parameter template if available."""
+ return getattr(value.parameter_template, 'pk', None)
+
+
+class ImportRequestSerializer(serializers.Serializer):
+ """Serializer for the import request."""
+
+ supplier = serializers.CharField(required=True)
+ part_import_id = serializers.CharField(required=True)
+ category_id = serializers.PrimaryKeyRelatedField(
+ queryset=part_models.PartCategory.objects.all(),
+ many=False,
+ required=False,
+ allow_null=True,
+ )
+ part_id = serializers.PrimaryKeyRelatedField(
+ queryset=part_models.Part.objects.all(),
+ many=False,
+ required=False,
+ allow_null=True,
+ )
+
+
+class ImportResultSerializer(serializers.Serializer):
+ """Serializer for the import result."""
+
+ class Meta:
+ """Meta options for the ImportResultSerializer."""
+
+ fields = [
+ 'part_id',
+ 'part_detail',
+ 'manufacturer_part_id',
+ 'supplier_part_id',
+ 'pricing',
+ 'parameters',
+ ]
+
+ part_id = serializers.IntegerField()
+ part_detail = PartSerializer()
+ manufacturer_part_id = serializers.IntegerField()
+ supplier_part_id = serializers.IntegerField()
+ pricing = serializers.SerializerMethodField()
+ parameters = ImportParameterSerializer(many=True)
+
+ def get_pricing(self, value: Any) -> list[tuple[float, str]]:
+ """Return the pricing data as a dictionary."""
+ return value['pricing']
diff --git a/src/backend/InvenTree/plugin/mixins/__init__.py b/src/backend/InvenTree/plugin/mixins/__init__.py
index cb137237c39a..f918a46e54c9 100644
--- a/src/backend/InvenTree/plugin/mixins/__init__.py
+++ b/src/backend/InvenTree/plugin/mixins/__init__.py
@@ -17,6 +17,7 @@
from plugin.base.integration.ValidationMixin import ValidationMixin
from plugin.base.label.mixins import LabelPrintingMixin
from plugin.base.locate.mixins import LocateMixin
+from plugin.base.supplier.mixins import SupplierMixin
from plugin.base.ui.mixins import UserInterfaceMixin
__all__ = [
@@ -37,6 +38,7 @@
'SettingsMixin',
'SingleNotificationMethod',
'SupplierBarcodeMixin',
+ 'SupplierMixin',
'UrlsMixin',
'UserInterfaceMixin',
'ValidationMixin',
diff --git a/src/backend/InvenTree/plugin/plugin.py b/src/backend/InvenTree/plugin/plugin.py
index f3c8f02f1ec2..2395f701363d 100644
--- a/src/backend/InvenTree/plugin/plugin.py
+++ b/src/backend/InvenTree/plugin/plugin.py
@@ -41,6 +41,7 @@ class PluginMixinEnum(StringEnum):
SCHEDULE = 'schedule'
SETTINGS = 'settings'
SETTINGS_CONTENT = 'settingscontent'
+ SUPPLIER = 'supplier'
SUPPLIER_BARCODE = 'supplier-barcode'
URLS = 'urls'
USER_INTERFACE = 'ui'
diff --git a/src/backend/InvenTree/plugin/samples/supplier/__init__.py b/src/backend/InvenTree/plugin/samples/supplier/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/src/backend/InvenTree/plugin/samples/supplier/supplier_sample.py b/src/backend/InvenTree/plugin/samples/supplier/supplier_sample.py
new file mode 100644
index 000000000000..0a7c41bb2b28
--- /dev/null
+++ b/src/backend/InvenTree/plugin/samples/supplier/supplier_sample.py
@@ -0,0 +1,174 @@
+"""Sample supplier plugin."""
+
+from company.models import Company, ManufacturerPart, SupplierPart, SupplierPriceBreak
+from part.models import Part
+from plugin.base.supplier.mixins import SupplierMixin
+from plugin.plugin import InvenTreePlugin
+
+
+class SampleSupplierPlugin(SupplierMixin, InvenTreePlugin):
+ """Example plugin to integrate with a dummy supplier."""
+
+ NAME = 'SampleSupplierPlugin'
+ SLUG = 'samplesupplier'
+ TITLE = 'My sample supplier plugin'
+
+ VERSION = '0.0.1'
+
+ SUPPLIER_NAME = 'Sample Supplier'
+
+ def __init__(self):
+ """Initialize the sample supplier plugin."""
+ super().__init__()
+
+ self.sample_data = []
+ for material in ['Steel', 'Aluminium', 'Brass']:
+ for size in ['M1', 'M2', 'M3', 'M4', 'M5']:
+ for length in range(5, 30, 5):
+ self.sample_data.append({
+ 'material': material,
+ 'thread': size,
+ 'length': length,
+ 'sku': f'BOLT-{material}-{size}-{length}',
+ 'name': f'Bolt {size}x{length}mm {material}',
+ 'description': f'This is a sample part description demonstration purposes for the {size}x{length} {material} bolt.',
+ 'price': {
+ 1: [1.0, 'EUR'],
+ 10: [0.9, 'EUR'],
+ 100: [0.8, 'EUR'],
+ 5000: [0.5, 'EUR'],
+ },
+ 'link': f'https://example.com/sample-part-{size}-{length}-{material}',
+ 'image_url': r'https://demo.inventree.org/media/part_images/flat-head.png',
+ 'brand': 'Bolt Manufacturer',
+ })
+
+ def get_search_results(self, term: str) -> list[SupplierMixin.SearchResult]:
+ """Return a list of search results based on the search term."""
+ return [
+ SupplierMixin.SearchResult(
+ sku=p['sku'],
+ name=p['name'],
+ description=p['description'],
+ exact=p['sku'] == term,
+ price=f'{p["price"][1][0]:.2f}€',
+ link=p['link'],
+ image_url=p['image_url'],
+ existing_part=getattr(
+ SupplierPart.objects.filter(SKU=p['sku']).first(), 'part', None
+ ),
+ )
+ for p in self.sample_data
+ if all(t.lower() in p['name'].lower() for t in term.split())
+ ]
+
+ def get_import_data(self, part_id: str):
+ """Return import data for a specific part ID."""
+ for p in self.sample_data:
+ if p['sku'] == part_id:
+ p = p.copy()
+ p['variants'] = [
+ x['sku']
+ for x in self.sample_data
+ if x['thread'] == p['thread'] and x['length'] == p['length']
+ ]
+ return p
+
+ raise SupplierMixin.PartNotFoundError()
+
+ def get_pricing_data(self, data) -> dict[int, tuple[float, str]]:
+ """Return pricing data for the given part data."""
+ return data['price']
+
+ def get_parameters(self, data) -> list[SupplierMixin.ImportParameter]:
+ """Return a list of parameters for the given part data."""
+ return [
+ SupplierMixin.ImportParameter(name='Thread', value=data['thread'][1:]),
+ SupplierMixin.ImportParameter(name='Length', value=f'{data["length"]}mm'),
+ SupplierMixin.ImportParameter(name='Material', value=data['material']),
+ SupplierMixin.ImportParameter(name='Head', value='Flat Head'),
+ ]
+
+ def import_part(self, data, **kwargs) -> Part:
+ """Import a part based on the provided data."""
+ part, created = Part.objects.get_or_create(
+ name__iexact=data['sku'],
+ purchaseable=True,
+ defaults={
+ 'name': data['sku'],
+ 'description': data['description'],
+ 'link': data['link'],
+ **kwargs,
+ },
+ )
+
+ # If the part was created, set additional fields
+ if created:
+ if data['image_url']:
+ file, fmt = self.download_image(data['image_url'])
+ filename = f'part_{part.pk}_image.{fmt.lower()}'
+ part.image.save(filename, file)
+
+ # link other variants if they exist in our inventree database
+ if len(data['variants']):
+ # search for other parts that may already have a template part associated
+ variant_parts = [
+ x.part
+ for x in SupplierPart.objects.filter(SKU__in=data['variants'])
+ ]
+ parent_part = self.get_template_part(
+ variant_parts,
+ {
+ # we cannot extract a real name for the root part, but we can try to guess a unique name
+ 'name': data['sku'].replace(data['material'] + '-', ''),
+ 'description': data['name'].replace(' ' + data['material'], ''),
+ 'link': data['link'],
+ 'image': part.image.name,
+ 'is_template': True,
+ **kwargs,
+ },
+ )
+ part.variant_of = parent_part
+ part.save()
+
+ return part
+
+ def import_manufacturer_part(self, data, **kwargs) -> ManufacturerPart:
+ """Import a manufacturer part based on the provided data."""
+ mft, _ = Company.objects.get_or_create(
+ name__iexact=data['brand'],
+ defaults={
+ 'is_manufacturer': True,
+ 'is_supplier': False,
+ 'name': data['brand'],
+ },
+ )
+
+ mft_part, created = ManufacturerPart.objects.get_or_create(
+ MPN=f'MAN-{data["sku"]}', manufacturer=mft, **kwargs
+ )
+
+ if created:
+ # Attachments, notes, parameters and more can be added here
+ pass
+
+ return mft_part
+
+ def import_supplier_part(self, data, **kwargs) -> SupplierPart:
+ """Import a supplier part based on the provided data."""
+ spp, _ = SupplierPart.objects.get_or_create(
+ SKU=data['sku'],
+ supplier=self.supplier,
+ **kwargs,
+ defaults={'link': data['link']},
+ )
+
+ SupplierPriceBreak.objects.filter(part=spp).delete()
+ SupplierPriceBreak.objects.bulk_create([
+ SupplierPriceBreak(
+ part=spp, quantity=quantity, price=price, price_currency=currency
+ )
+ for quantity, (price, currency) in data['price'].items()
+ ])
+
+ return spp
diff --git a/src/backend/InvenTree/plugin/samples/supplier/test_supplier_sample.py b/src/backend/InvenTree/plugin/samples/supplier/test_supplier_sample.py
new file mode 100644
index 000000000000..19083e3d9f8b
--- /dev/null
+++ b/src/backend/InvenTree/plugin/samples/supplier/test_supplier_sample.py
@@ -0,0 +1,103 @@
+"""Unit tests for locate_sample sample plugins."""
+
+from django.urls import reverse
+
+from company.models import ManufacturerPart, SupplierPart
+from InvenTree.unit_test import InvenTreeAPITestCase
+from part.models import Part
+from plugin import registry
+
+
+class SampleSupplierTest(InvenTreeAPITestCase):
+ """Tests for SampleSupplierPlugin."""
+
+ fixtures = ['location', 'category', 'part', 'stock', 'company']
+ roles = ['part.add']
+
+ def test_search(self):
+ """Check if the event is issued."""
+ # Activate plugin
+ config = registry.get_plugin('samplesupplier').plugin_config()
+ config.active = True
+ config.save()
+
+ # Test APIs
+ url = reverse('api-supplier-search')
+
+ # No supplier
+ self.get(url, {'supplier': 'non-existent-supplier'}, expected_code=404)
+
+ # valid supplier
+ res = self.get(
+ url, {'supplier': 'samplesupplier', 'term': 'M5'}, expected_code=200
+ )
+ self.assertEqual(len(res.data), 15)
+ self.assertEqual(res.data[0]['sku'], 'BOLT-Steel-M5-5')
+
+ def test_import_part(self):
+ """Test importing a part by supplier."""
+ # Activate plugin
+ plugin = registry.get_plugin('samplesupplier')
+ config = plugin.plugin_config()
+ config.active = True
+ config.save()
+ plugin.set_setting('SUPPLIER', 1)
+
+ # Test APIs
+ url = reverse('api-supplier-import')
+
+ # No supplier
+ self.post(url, {'supplier': 'non-existent-supplier'}, expected_code=400)
+
+ # valid supplier, no part or category provided
+ self.post(url, {'supplier': 'samplesupplier'}, expected_code=400)
+
+ # valid supplier, valid part import
+ res = self.post(
+ url,
+ {
+ 'supplier': 'samplesupplier',
+ 'part_import_id': 'BOLT-Steel-M5-5',
+ 'category_id': 1,
+ },
+ expected_code=200,
+ )
+ part = Part.objects.get(name='BOLT-Steel-M5-5')
+ self.assertIsNotNone(part)
+ self.assertEqual(part.pk, res.data['part_id'])
+
+ self.assertIsNotNone(SupplierPart.objects.get(pk=res.data['supplier_part_id']))
+ self.assertIsNotNone(
+ ManufacturerPart.objects.get(pk=res.data['manufacturer_part_id'])
+ )
+
+ # valid supplier, import only manufacturer and supplier part
+ part2 = Part.objects.create(name='Test Part', purchaseable=True)
+ res = self.post(
+ url,
+ {
+ 'supplier': 'samplesupplier',
+ 'part_import_id': 'BOLT-Steel-M5-10',
+ 'part_id': part2.pk,
+ },
+ expected_code=200,
+ )
+
+ self.assertEqual(part2.pk, res.data['part_id'])
+ sp = SupplierPart.objects.get(pk=res.data['supplier_part_id'])
+ mp = ManufacturerPart.objects.get(pk=res.data['manufacturer_part_id'])
+ self.assertIsNotNone(sp)
+ self.assertIsNotNone(mp)
+ self.assertEqual(sp.part.pk, part2.pk)
+ self.assertEqual(mp.part.pk, part2.pk)
+
+ # PartNotFoundError
+ self.post(
+ url,
+ {
+ 'supplier': 'samplesupplier',
+ 'part_import_id': 'non-existent-part',
+ 'category_id': 1,
+ },
+ expected_code=404,
+ )
diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx
index 96f08d422403..251e6c4e75eb 100644
--- a/src/frontend/lib/enums/ApiEndpoints.tsx
+++ b/src/frontend/lib/enums/ApiEndpoints.tsx
@@ -109,6 +109,7 @@ export enum ApiEndpoints {
part_list = 'part/',
part_parameter_list = 'part/parameter/',
part_parameter_template_list = 'part/parameter/template/',
+ part_parameter_bulk = 'part/parameter/bulk/',
part_thumbs_list = 'part/thumbs/',
part_pricing = 'part/:id/pricing/',
part_serial_numbers = 'part/:id/serial-numbers/',
@@ -218,6 +219,8 @@ export enum ApiEndpoints {
// Special plugin endpoints
plugin_locate_item = 'locate/',
+ plugin_supplier_search = 'supplier/search/',
+ plugin_supplier_import = 'supplier/import/',
// Machine API endpoints
machine_types_list = 'machine/types/',
diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx
index 01d409a2cfe7..043bdf54fcdb 100644
--- a/src/frontend/src/components/forms/ApiForm.tsx
+++ b/src/frontend/src/components/forms/ApiForm.tsx
@@ -114,6 +114,8 @@ export function OptionsApiForm({
if (!_props.fields) return _props;
+ _props.fields = { ..._props.fields };
+
for (const [k, v] of Object.entries(_props.fields)) {
_props.fields[k] = constructField({
field: v,
diff --git a/src/frontend/src/components/wizards/ImportPartWizard.tsx b/src/frontend/src/components/wizards/ImportPartWizard.tsx
new file mode 100644
index 000000000000..d721fbaa208b
--- /dev/null
+++ b/src/frontend/src/components/wizards/ImportPartWizard.tsx
@@ -0,0 +1,747 @@
+import { ApiEndpoints, ModelType, apiUrl } from '@lib/index';
+import { t } from '@lingui/core/macro';
+import { Trans } from '@lingui/react/macro';
+import {
+ ActionIcon,
+ Badge,
+ Box,
+ Button,
+ Center,
+ Checkbox,
+ Divider,
+ Group,
+ Loader,
+ Paper,
+ ScrollAreaAutosize,
+ Select,
+ Stack,
+ Text,
+ TextInput,
+ Title,
+ Tooltip
+} from '@mantine/core';
+import { showNotification } from '@mantine/notifications';
+import { IconArrowDown, IconPlus } from '@tabler/icons-react';
+import {
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState
+} from 'react';
+import { Link } from 'react-router-dom';
+import { api } from '../../App';
+import { usePartFields } from '../../forms/PartForms';
+import { InvenTreeIcon } from '../../functions/icons';
+import { useEditApiFormModal } from '../../hooks/UseForm';
+import { usePluginsWithMixin } from '../../hooks/UsePlugins';
+import useWizard from '../../hooks/UseWizard';
+import { StockItemTable } from '../../tables/stock/StockItemTable';
+import { StandaloneField } from '../forms/StandaloneField';
+import { RenderRemoteInstance } from '../render/Instance';
+
+type SearchResult = {
+ id: string;
+ sku: string;
+ name: string;
+ exact: boolean;
+ description?: string;
+ price?: string;
+ link?: string;
+ image_url?: string;
+ existing_part_id?: number;
+};
+
+type ImportResult = {
+ manufacturer_part_id: number;
+ supplier_part_id: number;
+ part_id: number;
+ pricing: { [priceBreak: number]: [number, string] };
+ part_detail: any;
+ parameters: {
+ name: string;
+ value: string;
+ parameter_template: number | null;
+ on_category: boolean;
+ }[];
+};
+
+const SearchResult = ({
+ searchResult,
+ partId,
+ rightSection
+}: {
+ searchResult: SearchResult;
+ partId?: number;
+ rightSection?: ReactNode;
+}) => {
+ return (
+
+
+ {searchResult.image_url && (
+
+ )}
+
+
+
+ {searchResult.name} ({searchResult.sku})
+
+
+ {searchResult.description}
+
+
+ {searchResult.price && (
+
+ {searchResult.price}
+
+ )}
+ {searchResult.exact && (
+
+ Exact Match
+
+ )}
+ {searchResult.existing_part_id &&
+ partId &&
+ searchResult.existing_part_id === partId && (
+
+ Current part
+
+ )}
+ {searchResult.existing_part_id && (
+
+
+ Already Imported
+
+
+ )}
+
+ {rightSection}
+
+
+
+ );
+};
+
+const SearchStep = ({
+ selectSupplierPart,
+ partId
+}: {
+ selectSupplierPart: (props: {
+ supplier: string;
+ searchResult: SearchResult;
+ }) => void;
+ partId?: number;
+}) => {
+ const [searchValue, setSearchValue] = useState('');
+ const [supplier, setSupplier] = useState('');
+ const supplierPlugins = usePluginsWithMixin('supplier');
+ const [searchResults, setSearchResults] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleSearch = useCallback(async () => {
+ setIsLoading(true);
+ const res = await api.get(apiUrl(ApiEndpoints.plugin_supplier_search), {
+ params: {
+ supplier,
+ term: searchValue
+ }
+ });
+ setSearchResults(res.data ?? []);
+ setIsLoading(false);
+ }, [supplier, searchValue]);
+
+ useEffect(() => {
+ if (supplier === '' && supplierPlugins.length > 0) {
+ setSupplier(supplierPlugins[0].meta.slug ?? '');
+ }
+ }, [supplierPlugins]);
+
+ return (
+
+
+ setSearchValue(event.currentTarget.value)}
+ />
+
+
+ {isLoading && (
+
+
+
+ )}
+
+ {!isLoading && (
+
+ Found {searchResults.length} results
+
+ )}
+
+
+
+ {searchResults.map((res) => (
+
+
+ selectSupplierPart({
+ supplier,
+ searchResult: res
+ })
+ }
+ >
+
+
+
+ )
+ }
+ />
+ ))}
+
+
+
+ );
+};
+
+const CategoryStep = ({
+ categoryId,
+ importPart,
+ isImporting
+}: {
+ isImporting: boolean;
+ categoryId?: number;
+ importPart: (categoryId: number) => void;
+}) => {
+ const [category, setCategory] = useState(categoryId);
+
+ return (
+
+ setCategory(value)
+ }}
+ />
+
+
+
+ Are you sure you want to import this part into the selected category
+ now?
+
+
+
+
+
+
+
+ );
+};
+
+type ParametersType = (ImportResult['parameters'][number] & { use: boolean })[];
+
+const ParametersStep = ({
+ importResult,
+ isImporting,
+ skipStep,
+ importParameters,
+ parameterErrors
+}: {
+ importResult: ImportResult;
+ isImporting: boolean;
+ skipStep: () => void;
+ importParameters: (parameters: ParametersType) => Promise;
+ parameterErrors: string[] | null;
+}) => {
+ const [parameters, setParameters] = useState(() =>
+ importResult.parameters.map((p) => ({
+ ...p,
+ use: p.parameter_template !== null
+ }))
+ );
+ const [categoryCount, otherCount] = useMemo(() => {
+ const c = parameters.filter((x) => x.on_category && x.use).length;
+ const o = parameters.filter((x) => !x.on_category && x.use).length;
+ return [c, o];
+ }, [parameters]);
+ const parametersFromCategory = useMemo(
+ () => parameters.filter((x) => x.on_category).length,
+ [parameters]
+ );
+ const setParameter = useCallback(
+ (i: number, key: string) => (e: unknown) =>
+ setParameters((p) => p.map((p, j) => (i === j ? { ...p, [key]: e } : p))),
+ []
+ );
+
+ return (
+
+
+
+ Select and edit the parameters you want to add to this part.
+
+
+
+ {parametersFromCategory > 0 && (
+
+ Default category parameters
+ {categoryCount}
+
+ )}
+
+ {parameters.map((p, i) => (
+
+ {p.on_category === false &&
+ parameters[i - 1]?.on_category === true && (
+ <>
+
+
+ Other parameters
+ {otherCount}
+
+ >
+ )}
+
+
+ setParameter(i, 'use')(e.currentTarget.checked)
+ }
+ />
+ {!p.on_category && (
+
+
+ {p.name}
+
+
+ )}
+
+
+ setParameter(i, 'parameter_template')(v)
+ }}
+ />
+
+
+ setParameter(i, 'value')(e.currentTarget.value)
+ }
+ error={parameterErrors ? parameterErrors[i] : undefined}
+ />
+
+
+ ))}
+
+
+ {
+ setParameters((p) => [
+ ...p,
+ {
+ name: '',
+ value: '',
+ parameter_template: null,
+ on_category: false,
+ use: true
+ }
+ ]);
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const StockStep = ({
+ importResult,
+ nextStep
+}: {
+ importResult: ImportResult;
+ nextStep: () => void;
+}) => {
+ return (
+
+
+ Create initial stock for the imported part.
+
+
+
+
+
+
+
+
+ );
+};
+
+export default function ImportPartWizard({
+ categoryId,
+ partId
+}: {
+ categoryId?: number;
+ partId?: number;
+}) {
+ const [supplierPart, setSupplierPart] = useState<{
+ supplier: string;
+ searchResult: SearchResult;
+ }>();
+ const [importResult, setImportResult] = useState();
+ const [isImporting, setIsImporting] = useState(false);
+ const [parameterErrors, setParameterErrors] = useState(null);
+
+ const partFields = usePartFields({ create: false });
+ const editPart = useEditApiFormModal({
+ url: ApiEndpoints.part_list,
+ pk: importResult?.part_id,
+ title: t`Edit Part`,
+ fields: partFields
+ });
+
+ const importPart = useCallback(
+ async ({
+ categoryId,
+ partId
+ }: { categoryId?: number; partId?: number }) => {
+ setIsImporting(true);
+ try {
+ const importResult = await api.post(
+ apiUrl(ApiEndpoints.plugin_supplier_import),
+ {
+ category_id: categoryId,
+ part_import_id: supplierPart?.searchResult.id,
+ supplier: supplierPart?.supplier,
+ part_id: partId
+ },
+ {
+ timeout: 30000 // 30 seconds
+ }
+ );
+ setImportResult(importResult.data);
+ showNotification({
+ title: t`Success`,
+ message: t`Part imported successfully!`,
+ color: 'green'
+ });
+ wizard.nextStep();
+ setIsImporting(false);
+ } catch {
+ showNotification({
+ title: t`Error`,
+ message: t`Failed to import part`,
+ color: 'red'
+ });
+ setIsImporting(false);
+ }
+ },
+ [supplierPart]
+ );
+
+ // Render the select wizard step
+ const renderStep = useCallback(
+ (step: number) => {
+ return (
+
+ {editPart.modal}
+
+ {step > 0 && supplierPart && (
+
+
+
+
+ {
+ editPart.open();
+ }}
+ >
+
+
+
+ )
+ }
+ />
+ )}
+
+ {step === 0 && (
+ {
+ setSupplierPart(sp);
+ wizard.nextStep();
+ }}
+ partId={partId}
+ />
+ )}
+
+ {!partId && step === 1 && (
+ {
+ importPart({ categoryId });
+ }}
+ />
+ )}
+
+ {!!partId && step === 1 && (
+
+
+
+
+
+ Are you sure, you want to import the supplier and manufacturer
+ part into this part?
+
+
+
+
+
+
+
+ )}
+
+ {!partId && step === 2 && (
+ {
+ setIsImporting(true);
+ setParameterErrors(null);
+ const useParameters = parameters
+ .map((x, i) => ({ ...x, i }))
+ .filter((p) => p.use);
+ const map = useParameters.reduce(
+ (acc, p, i) => {
+ acc[p.i] = i;
+ return acc;
+ },
+ {} as Record
+ );
+ const createParameters = useParameters.map((p) => ({
+ part: importResult!.part_id,
+ template: p.parameter_template,
+ data: p.value
+ }));
+ try {
+ await api.post(
+ apiUrl(ApiEndpoints.part_parameter_bulk),
+ createParameters
+ );
+ showNotification({
+ title: t`Success`,
+ message: t`Parameters created successfully!`,
+ color: 'green'
+ });
+ wizard.nextStep();
+ setIsImporting(false);
+ } catch (err: any) {
+ if (
+ err?.response?.status === 400 &&
+ Array.isArray(err.response.data)
+ ) {
+ const errors = err.response.data.map(
+ (e: Record) =>
+ Object.entries(e)
+ .map(
+ ([k, v]) =>
+ `${k === 'data' ? '' : `${k}: `}${v.join(',')}`
+ )
+ .join(', ')
+ );
+ setParameterErrors(
+ parameters.map((_, i) =>
+ map[i] !== undefined && errors[map[i]]
+ ? errors[map[i]]
+ : ''
+ )
+ );
+ }
+ showNotification({
+ title: t`Error`,
+ message: t`Failed to create parameters, please fix the errors and try again`,
+ color: 'red'
+ });
+ setIsImporting(false);
+ }
+ }}
+ skipStep={() => wizard.nextStep()}
+ />
+ )}
+
+ {step === (!partId ? 3 : 2) && (
+ wizard.nextStep()}
+ />
+ )}
+
+ {step === (!partId ? 4 : 3) && (
+
+
+
+ Part imported successfully from supplier{' '}
+ {supplierPart?.supplier}.
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+ },
+ [
+ partId,
+ categoryId,
+ supplierPart,
+ importResult,
+ isImporting,
+ parameterErrors,
+ importPart,
+ editPart.modal
+ ]
+ );
+
+ const onClose = useCallback(() => {
+ setSupplierPart(undefined);
+ setImportResult(undefined);
+ setIsImporting(false);
+ setParameterErrors(null);
+ wizard.setStep(0);
+ }, []);
+
+ // Create the wizard manager
+ const wizard = useWizard({
+ title: t`Import Part`,
+ steps: [
+ t`Search Supplier Part`,
+ // if partId is provided, a inventree part already exists, just import the mp/sp
+ ...(!partId ? [t`Category`, t`Parameters`] : [t`Confirm import`]),
+ t`Stock`,
+ t`Done`
+ ],
+ onClose,
+ renderStep: renderStep,
+ disableManualStepChange: true
+ });
+
+ return wizard;
+}
diff --git a/src/frontend/src/components/wizards/WizardDrawer.tsx b/src/frontend/src/components/wizards/WizardDrawer.tsx
index 20cac1766306..1d5ea53ae921 100644
--- a/src/frontend/src/components/wizards/WizardDrawer.tsx
+++ b/src/frontend/src/components/wizards/WizardDrawer.tsx
@@ -5,7 +5,6 @@ import {
Divider,
Drawer,
Group,
- Paper,
Space,
Stack,
Stepper,
@@ -26,11 +25,13 @@ import { StylishText } from '../items/StylishText';
function WizardProgressStepper({
currentStep,
steps,
- onSelectStep
+ onSelectStep,
+ disableManualStepChange = false
}: {
currentStep: number;
steps: string[];
onSelectStep: (step: number) => void;
+ disableManualStepChange?: boolean;
}) {
if (!steps || steps.length == 0) {
return null;
@@ -54,23 +55,32 @@ function WizardProgressStepper({
return (
-
-
- onSelectStep(currentStep - 1)}
+
+ {!disableManualStepChange && (
+
-
-
-
+ onSelectStep(currentStep - 1)}
+ disabled={!canStepBackward}
+ >
+
+
+
+ )}
onSelectStep(stepIndex)}
+ onStepClick={(stepIndex: number) => {
+ if (disableManualStepChange) return;
+ onSelectStep(stepIndex);
+ }}
iconSize={20}
size='xs'
>
@@ -84,19 +94,21 @@ function WizardProgressStepper({
))}
{canStepForward ? (
-
- onSelectStep(currentStep + 1)}
+ !disableManualStepChange && (
+
-
-
-
+ onSelectStep(currentStep + 1)}
+ disabled={!canStepForward || disableManualStepChange}
+ >
+
+
+
+ )
) : (
@@ -120,7 +132,8 @@ export default function WizardDrawer({
opened,
onClose,
onNextStep,
- onPreviousStep
+ onPreviousStep,
+ disableManualStepChange
}: {
title: string;
currentStep: number;
@@ -130,6 +143,7 @@ export default function WizardDrawer({
onClose: () => void;
onNextStep?: () => void;
onPreviousStep?: () => void;
+ disableManualStepChange?: boolean;
}) {
const titleBlock: ReactNode = useMemo(() => {
return (
@@ -145,7 +159,9 @@ export default function WizardDrawer({
{
+ if (disableManualStepChange) return;
if (step < currentStep) {
onPreviousStep?.();
} else {
@@ -179,10 +195,7 @@ export default function WizardDrawer({
opened={opened}
onClose={onClose}
>
-
- {}
- {children}
-
+ {children}
);
}
diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx
index 1f2f5c5e2b7b..243291ea2b69 100644
--- a/src/frontend/src/forms/StockForms.tsx
+++ b/src/frontend/src/forms/StockForms.tsx
@@ -66,23 +66,34 @@ import { StatusFilterOptions } from '../tables/Filter';
export function useStockFields({
partId,
stockItem,
- create = false
+ create = false,
+ supplierPartId,
+ pricing
}: {
partId?: number;
stockItem?: any;
create: boolean;
+ supplierPartId?: number;
+ pricing?: { [priceBreak: number]: [number, string] };
}): ApiFormFieldSet {
const globalSettings = useGlobalSettingsState();
// Keep track of the "part" instance
const [partInstance, setPartInstance] = useState({});
- const [supplierPart, setSupplierPart] = useState(null);
+ const [supplierPart, setSupplierPart] = useState(
+ supplierPartId ?? null
+ );
const [nextBatchCode, setNextBatchCode] = useState('');
const [nextSerialNumber, setNextSerialNumber] = useState('');
const [expiryDate, setExpiryDate] = useState(null);
+ const [quantity, setQuantity] = useState(null);
+ const [purchasePrice, setPurchasePrice] = useState(null);
+ const [purchasePriceCurrency, setPurchasePriceCurrency] = useState<
+ string | null
+ >(null);
const batchGenerator = useBatchCodeGenerator((value: any) => {
if (value) {
@@ -100,6 +111,25 @@ export function useStockFields({
}
});
+ // Update pricing when quantity changes
+ useEffect(() => {
+ if (quantity === null || quantity === undefined || !pricing) return;
+
+ // Find the highest price break that is less than or equal to the quantity
+ const priceBreak = Object.entries(pricing)
+ .sort(([a], [b]) => Number.parseInt(b) - Number.parseInt(a))
+ .find(([br]) => quantity >= Number.parseInt(br));
+
+ if (priceBreak) {
+ setPurchasePrice(priceBreak[1][0]);
+ setPurchasePriceCurrency(priceBreak[1][1]);
+ }
+ }, [pricing, quantity]);
+
+ useEffect(() => {
+ if (supplierPartId && !supplierPart) setSupplierPart(supplierPartId);
+ }, [partInstance, supplierPart, supplierPartId]);
+
useEffect(() => {
if (partInstance?.pk) {
// Update the generators whenever the part ID changes
@@ -112,7 +142,7 @@ export function useStockFields({
const fields: ApiFormFieldSet = {
part: {
value: partInstance.pk,
- disabled: !create,
+ disabled: !create || !!partId,
filters: {
active: create ? true : undefined
},
@@ -136,6 +166,7 @@ export function useStockFields({
},
supplier_part: {
hidden: partInstance?.purchaseable == false,
+ disabled: !!supplierPartId,
value: supplierPart,
onValueChange: (value) => {
setSupplierPart(value);
@@ -170,8 +201,10 @@ export function useStockFields({
quantity: {
hidden: !create,
description: t`Enter initial quantity for this stock item`,
+ value: quantity,
onValueChange: (value) => {
batchGenerator.update({ quantity: value });
+ setQuantity(value);
}
},
serial_numbers: {
@@ -204,10 +237,18 @@ export function useStockFields({
}
},
purchase_price: {
- icon:
+ icon: ,
+ value: purchasePrice,
+ onValueChange: (value) => {
+ setPurchasePrice(value);
+ }
},
purchase_price_currency: {
- icon:
+ icon: ,
+ value: purchasePriceCurrency,
+ onValueChange: (value) => {
+ setPurchasePriceCurrency(value);
+ }
},
packaging: {
icon:
@@ -236,7 +277,10 @@ export function useStockFields({
supplierPart,
nextSerialNumber,
nextBatchCode,
- create
+ create,
+ supplierPartId,
+ purchasePrice,
+ purchasePriceCurrency
]);
}
diff --git a/src/frontend/src/hooks/UseWizard.tsx b/src/frontend/src/hooks/UseWizard.tsx
index e8f06f9953fd..7e9d45224e21 100644
--- a/src/frontend/src/hooks/UseWizard.tsx
+++ b/src/frontend/src/hooks/UseWizard.tsx
@@ -12,6 +12,8 @@ import WizardDrawer from '../components/wizards/WizardDrawer';
export interface WizardProps {
title: string;
steps: string[];
+ disableManualStepChange?: boolean;
+ onClose?: () => void;
renderStep: (step: number) => ReactNode;
canStepForward?: (step: number) => boolean;
canStepBackward?: (step: number) => boolean;
@@ -30,6 +32,7 @@ export interface WizardState {
nextStep: () => void;
previousStep: () => void;
wizard: ReactNode;
+ setStep: (step: number) => void;
}
/**
@@ -65,32 +68,44 @@ export default function useWizard(props: WizardProps): WizardState {
// Close the wizard
const closeWizard = useCallback(() => {
+ props.onClose?.();
setOpened(false);
}, []);
// Progress the wizard to the next step
const nextStep = useCallback(() => {
- if (props.canStepForward && !props.canStepForward(currentStep)) {
- return;
- }
-
- if (props.steps && currentStep < props.steps.length - 1) {
- setCurrentStep(currentStep + 1);
- clearError();
- }
- }, [currentStep, props.canStepForward]);
+ setCurrentStep((c) => {
+ if (props.canStepForward && !props.canStepForward(c)) {
+ return c;
+ }
+ const newStep = Math.min(c + 1, props.steps.length - 1);
+ if (newStep !== c) clearError();
+ return newStep;
+ });
+ }, [props.canStepForward]);
// Go back to the previous step
const previousStep = useCallback(() => {
- if (props.canStepBackward && !props.canStepBackward(currentStep)) {
- return;
- }
+ setCurrentStep((c) => {
+ if (props.canStepBackward && !props.canStepBackward(c)) {
+ return c;
+ }
+ const newStep = Math.max(c - 1, 0);
+ if (newStep !== c) clearError();
+ return newStep;
+ });
+ }, [props.canStepBackward]);
- if (currentStep > 0) {
- setCurrentStep(currentStep - 1);
+ const setStep = useCallback(
+ (step: number) => {
+ if (step < 0 || step >= props.steps.length) {
+ return;
+ }
+ setCurrentStep(step);
clearError();
- }
- }, [currentStep, props.canStepBackward]);
+ },
+ [props.steps.length]
+ );
// Render the wizard contents for the current step
const contents = useMemo(() => {
@@ -109,8 +124,10 @@ export default function useWizard(props: WizardProps): WizardState {
closeWizard,
nextStep,
previousStep,
+ setStep,
wizard: (
{
return [
@@ -390,6 +395,14 @@ export function PartListTable({
hidden={!user.hasAddRole(UserRoles.part)}
tooltip={t`Add Part`}
onClick={() => newPart.open()}
+ />,
+ }
+ onClick={() => importPartWizard.openWizard()}
/>
];
}, [user, table.hasSelectedRecords]);
@@ -399,6 +412,7 @@ export function PartListTable({
{newPart.modal}
{setCategory.modal}
{orderPartsWizard.wizard}
+ {importPartWizard.wizard}
{
return [
addSupplierPart.open()}
hidden={!user.hasAddRole(UserRoles.purchase_order)}
+ />,
+ }
+ color='green'
+ tooltip={t`Import supplier part`}
+ onClick={() => importPartWizard.openWizard()}
+ hidden={!user.hasAddRole(UserRoles.part) || !params?.part}
/>
];
}, [user]);
@@ -258,6 +273,7 @@ export function SupplierPartTable({
{addSupplierPart.modal}
{editSupplierPart.modal}
{deleteSupplierPart.modal}
+ {importPartWizard.wizard}