From 2c6fedb17e104df9eeec29aadf1e53dfa4b06e72 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Wed, 11 Jun 2025 19:56:19 +0200 Subject: [PATCH 1/8] commit initial draft for supplier import --- .../InvenTree/InvenTree/serializers.py | 49 ++ src/backend/InvenTree/part/api.py | 23 + src/backend/InvenTree/part/serializers.py | 19 + src/backend/InvenTree/plugin/api.py | 2 + .../plugin/base/supplier/__init__.py | 0 .../InvenTree/plugin/base/supplier/api.py | 185 ++++++ .../InvenTree/plugin/base/supplier/mixins.py | 222 +++++++ .../plugin/base/supplier/serializers.py | 104 +++ .../InvenTree/plugin/mixins/__init__.py | 2 + src/backend/InvenTree/plugin/plugin.py | 1 + .../plugin/samples/supplier/__init__.py | 0 .../samples/supplier/supplier_sample.py | 32 + src/frontend/lib/enums/ApiEndpoints.tsx | 3 + .../components/wizards/ImportPartWizard.tsx | 617 ++++++++++++++++++ .../src/components/wizards/WizardDrawer.tsx | 75 ++- src/frontend/src/hooks/UseWizard.tsx | 2 + src/frontend/src/tables/part/PartTable.tsx | 16 +- 17 files changed, 1320 insertions(+), 32 deletions(-) create mode 100644 src/backend/InvenTree/plugin/base/supplier/__init__.py create mode 100644 src/backend/InvenTree/plugin/base/supplier/api.py create mode 100644 src/backend/InvenTree/plugin/base/supplier/mixins.py create mode 100644 src/backend/InvenTree/plugin/base/supplier/serializers.py create mode 100644 src/backend/InvenTree/plugin/samples/supplier/__init__.py create mode 100644 src/backend/InvenTree/plugin/samples/supplier/supplier_sample.py create mode 100644 src/frontend/src/components/wizards/ImportPartWizard.tsx 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 480ea7883ac1..d68dbb397f62 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 @@ -1702,6 +1703,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.""" @@ -2184,6 +2202,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 1522304d7a57..9fbe60fcc3cc 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -473,6 +473,25 @@ 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 + + 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..1fa5ae46b780 --- /dev/null +++ b/src/backend/InvenTree/plugin/base/supplier/api.py @@ -0,0 +1,185 @@ +"""API views for supplier plugins in InvenTree.""" + +from django.db import transaction +from django.urls import path + +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, + ] + + 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, + ] + + 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..3de14c229067 --- /dev/null +++ b/src/backend/InvenTree/plugin/base/supplier/mixins.py @@ -0,0 +1,222 @@ +"""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[int, 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 + + def get_unknown_manufacturer(self): + """Return the 'unknown manufacturer' company object.""" + mft_pk = 1 # self.plugin.get_setting("UNKNOWN_MANUFACTURER") # TODO: get from settings + return company.models.Company.objects.get(pk=mft_pk) 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..87873f502411 --- /dev/null +++ b/src/backend/InvenTree/plugin/base/supplier/serializers.py @@ -0,0 +1,104 @@ +"""Serializer definitions for the supplier plugin base.""" + +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): + """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): + """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: dict[str, list[tuple[int, 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..993a1ef23cab --- /dev/null +++ b/src/backend/InvenTree/plugin/samples/supplier/supplier_sample.py @@ -0,0 +1,32 @@ +"""Sample supplier plugin.""" + +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 get_search_results(self, term: str) -> list[SupplierMixin.SearchResult]: + """Return a list of search results based on the search term.""" + return [ + SupplierMixin.SearchResult( + sku=f'SAMPLE-001-{idx}', + name=f'Sample Part 1 T: {term}', + exact=True, + description='This is a sample part for demonstration purposes.', + price='10.00€', + link='https://example.com/sample-part-1', + image_url=r'https://cdn-reichelt.de/bilder/web/artikel_ws/D330%2FEINHELL_3410310_01.jpg?type=Product&', + id=f'sample-001-{idx}', + ) + for idx in range(20) + ] 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/wizards/ImportPartWizard.tsx b/src/frontend/src/components/wizards/ImportPartWizard.tsx new file mode 100644 index 000000000000..eb744c3baac5 --- /dev/null +++ b/src/frontend/src/components/wizards/ImportPartWizard.tsx @@ -0,0 +1,617 @@ +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 { InvenTreeIcon } from '../../functions/icons'; +import { usePluginsWithMixin } from '../../hooks/UsePlugins'; +import useWizard from '../../hooks/UseWizard'; +import { StandaloneField } from '../forms/StandaloneField'; + +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.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)} + /> +