diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 9dee3bd637..409c19aad0 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,15 @@ # InvenTree API version -INVENTREE_API_VERSION = 151 +INVENTREE_API_VERSION = 152 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v152 -> 2023-11-20 : https://github.com/inventree/InvenTree/pull/5949 + - Adds barcode support for manufacturerpart model + - Adds API endpoint for adding parts to purchase order using barcode scan + v151 -> 2023-11-13 : https://github.com/inventree/InvenTree/pull/5906 - Allow user list API to be filtered by user active status - Allow owner list API to be filtered by user active status diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index a7217bb8e0..a4b147ff08 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -969,6 +969,22 @@ class InvenTreeBarcodeMixin(models.Model): **kwargs ) + def format_matched_response(self): + """Format a standard response for a matched barcode.""" + + data = { + 'pk': self.pk, + } + + if hasattr(self, 'get_api_url'): + api_url = self.get_api_url() + data['api_url'] = f"{api_url}{self.pk}/" + + if hasattr(self, 'get_absolute_url'): + data['web_url'] = self.get_absolute_url() + + return data + @property def barcode(self): """Format a minimal barcode string (e.g. for label printing)""" diff --git a/InvenTree/company/migrations/0068_auto_20231120_1108.py b/InvenTree/company/migrations/0068_auto_20231120_1108.py new file mode 100644 index 0000000000..6a4cba6e29 --- /dev/null +++ b/InvenTree/company/migrations/0068_auto_20231120_1108.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.23 on 2023-11-20 11:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0067_alter_supplierpricebreak_price_currency'), + ] + + operations = [ + migrations.AddField( + model_name='manufacturerpart', + name='barcode_data', + field=models.CharField(blank=True, help_text='Third party barcode data', max_length=500, verbose_name='Barcode Data'), + ), + migrations.AddField( + model_name='manufacturerpart', + name='barcode_hash', + field=models.CharField(blank=True, help_text='Unique hash of barcode data', max_length=128, verbose_name='Barcode Hash'), + ), + ] diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 465df22125..693a35c570 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -387,7 +387,7 @@ class Address(models.Model): help_text=_('Link to address information (external)')) -class ManufacturerPart(MetadataMixin, models.Model): +class ManufacturerPart(MetadataMixin, InvenTreeBarcodeMixin, models.Model): """Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers. Attributes: diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index 860818f8dd..6d5911749b 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -223,6 +223,7 @@ class ManufacturerPartSerializer(InvenTreeTagModelSerializer): 'description', 'MPN', 'link', + 'barcode_hash', 'tags', ] diff --git a/InvenTree/plugin/base/barcodes/api.py b/InvenTree/plugin/base/barcodes/api.py index 09b9899d3e..6d89631604 100644 --- a/InvenTree/plugin/base/barcodes/api.py +++ b/InvenTree/plugin/base/barcodes/api.py @@ -59,29 +59,12 @@ class BarcodeView(CreateAPIView): """ raise NotImplementedError(f"handle_barcode not implemented for {self.__class__}") + def scan_barcode(self, barcode: str, request, **kwargs): + """Perform a generic 'scan' of the provided barcode data. -class BarcodeScan(BarcodeView): - """Endpoint for handling generic barcode scan requests. - - Barcode data are decoded by the client application, - and sent to this endpoint (as a JSON object) for validation. - - A barcode could follow the internal InvenTree barcode format, - or it could match to a third-party barcode format (e.g. Digikey). - """ - - def handle_barcode(self, barcode: str, request, **kwargs): - """Perform barcode scan action - - Arguments: - barcode: Raw barcode value - request: HTTP request object - - kwargs: - Any custom fields passed by the specific serializer + Check each loaded plugin, and return the first valid match """ - # Note: the default barcode handlers are loaded (and thus run) first plugins = registry.with_mixin('barcode') # Look for a barcode plugin which knows how to deal with this barcode @@ -110,14 +93,39 @@ class BarcodeScan(BarcodeView): response['barcode_data'] = barcode response['barcode_hash'] = hash_barcode(barcode) - # A plugin has not been found! - if plugin is None: - response['error'] = _('No match found for barcode data') + return response - raise ValidationError(response) - else: - response['success'] = _('Match found for barcode data') - return Response(response) + +class BarcodeScan(BarcodeView): + """Endpoint for handling generic barcode scan requests. + + Barcode data are decoded by the client application, + and sent to this endpoint (as a JSON object) for validation. + + A barcode could follow the internal InvenTree barcode format, + or it could match to a third-party barcode format (e.g. Digikey). + """ + + def handle_barcode(self, barcode: str, request, **kwargs): + """Perform barcode scan action + + Arguments: + barcode: Raw barcode value + request: HTTP request object + + kwargs: + Any custom fields passed by the specific serializer + """ + + result = self.scan_barcode(barcode, request, **kwargs) + + if result['plugin'] is None: + result['error'] = _('No match found for barcode data') + + raise ValidationError(result) + + result['success'] = _('Match found for barcode data') + return Response(result) class BarcodeAssign(BarcodeView): @@ -254,6 +262,98 @@ class BarcodeUnassign(BarcodeView): }) +class BarcodePOAllocate(BarcodeView): + """Endpoint for allocating parts to a purchase order by scanning their barcode + + Note that the scanned barcode may point to: + + - A Part object + - A ManufacturerPart object + - A SupplierPart object + """ + + serializer_class = barcode_serializers.BarcodePOAllocateSerializer + + def get_supplier_part(self, purchase_order, part=None, supplier_part=None, manufacturer_part=None): + """Return a single matching SupplierPart (or else raise an exception) + + Arguments: + purchase_order: PurchaseOrder object + part: Part object (optional) + supplier_part: SupplierPart object (optional) + manufacturer_part: ManufacturerPart object (optional) + + Returns: + SupplierPart object + + Raises: + ValidationError if no matching SupplierPart is found + + """ + + import company.models + + supplier = purchase_order.supplier + + supplier_parts = company.models.SupplierPart.objects.filter(supplier=supplier) + + if not part and not supplier_part and not manufacturer_part: + raise ValidationError({ + 'error': _('No matching part data found'), + }) + + if part: + if part_id := part.get('pk', None): + supplier_parts = supplier_parts.filter(part__pk=part_id) + + if supplier_part: + if supplier_part_id := supplier_part.get('pk', None): + supplier_parts = supplier_parts.filter(pk=supplier_part_id) + + if manufacturer_part: + if manufacturer_part_id := manufacturer_part.get('pk', None): + supplier_parts = supplier_parts.filter(manufacturer_part__pk=manufacturer_part_id) + + if supplier_parts.count() == 0: + raise ValidationError({ + "error": _("No matching supplier parts found") + }) + + if supplier_parts.count() > 1: + raise ValidationError({ + "error": _("Multiple matching supplier parts found") + }) + + # At this stage, we have a single matching supplier part + return supplier_parts.first() + + def handle_barcode(self, barcode: str, request, **kwargs): + """Scan the provided barcode data""" + + # The purchase order is provided as part of the request + purchase_order = kwargs.get('purchase_order') + + result = self.scan_barcode(barcode, request, **kwargs) + + if result['plugin'] is None: + result['error'] = _('No match found for barcode data') + raise ValidationError(result) + + supplier_part = self.get_supplier_part( + purchase_order, + part=result.get('part', None), + supplier_part=result.get('supplierpart', None), + manufacturer_part=result.get('manufacturerpart', None), + ) + + result['success'] = _("Matched supplier part") + result['supplierpart'] = supplier_part.format_matched_response() + + # TODO: Determine the 'quantity to order' for the supplier part + + return Response(result) + + class BarcodePOReceive(BarcodeView): """Endpoint for handling receiving parts by scanning their barcode. @@ -345,6 +445,9 @@ barcode_api_urls = [ # Receive a purchase order item by scanning its barcode path("po-receive/", BarcodePOReceive.as_view(), name="api-barcode-po-receive"), + # Allocate parts to a purchase order by scanning their barcode + path("po-allocate/", BarcodePOAllocate.as_view(), name="api-barcode-po-allocate"), + # Catch-all performs barcode 'scan' re_path(r'^.*$', BarcodeScan.as_view(), name='api-barcode-scan'), ] diff --git a/InvenTree/plugin/base/barcodes/serializers.py b/InvenTree/plugin/base/barcodes/serializers.py index 758f8c048b..584e3605ef 100644 --- a/InvenTree/plugin/base/barcodes/serializers.py +++ b/InvenTree/plugin/base/barcodes/serializers.py @@ -69,6 +69,27 @@ class BarcodeUnassignSerializer(BarcodeAssignMixin): fields = BarcodeAssignMixin.get_model_fields() +class BarcodePOAllocateSerializer(BarcodeSerializer): + """Serializer for allocating items against a purchase order. + + The scanned barcode could be a Part, ManufacturerPart or SupplierPart object + """ + + purchase_order = serializers.PrimaryKeyRelatedField( + queryset=order.models.PurchaseOrder.objects.all(), + required=True, + help_text=_('PurchaseOrder to allocate items against'), + ) + + def validate_purchase_order(self, order: order.models.PurchaseOrder): + """Validate the provided order""" + + if order.status != PurchaseOrderStatus.PENDING.value: + raise ValidationError(_("Purchase order is not pending")) + + return order + + class BarcodePOReceiveSerializer(BarcodeSerializer): """Serializer for receiving items against a purchase order. @@ -80,28 +101,28 @@ class BarcodePOReceiveSerializer(BarcodeSerializer): purchase_order = serializers.PrimaryKeyRelatedField( queryset=order.models.PurchaseOrder.objects.all(), - required=False, + required=False, allow_null=True, help_text=_('PurchaseOrder to receive items against'), ) def validate_purchase_order(self, order: order.models.PurchaseOrder): """Validate the provided order""" - if order.status != PurchaseOrderStatus.PLACED.value: + if order and order.status != PurchaseOrderStatus.PLACED.value: raise ValidationError(_("Purchase order has not been placed")) return order location = serializers.PrimaryKeyRelatedField( queryset=stock.models.StockLocation.objects.all(), - required=False, + required=False, allow_null=True, help_text=_('Location to receive items into'), ) def validate_location(self, location: stock.models.StockLocation): """Validate the provided location""" - if location.structural: + if location and location.structural: raise ValidationError(_("Cannot select a structural location")) return location diff --git a/InvenTree/plugin/builtin/barcodes/inventree_barcode.py b/InvenTree/plugin/builtin/barcodes/inventree_barcode.py index c98243ff96..7b0e7a4f7f 100644 --- a/InvenTree/plugin/builtin/barcodes/inventree_barcode.py +++ b/InvenTree/plugin/builtin/barcodes/inventree_barcode.py @@ -34,29 +34,8 @@ class InvenTreeInternalBarcodePlugin(BarcodeMixin, InvenTreePlugin): def format_matched_response(self, label, model, instance): """Format a response for the scanned data""" - data = { - 'pk': instance.pk - } - # Add in the API URL if available - if hasattr(model, 'get_api_url'): - data['api_url'] = f"{model.get_api_url()}{instance.pk}/" - - # Add in the web URL if available - if hasattr(instance, 'get_absolute_url'): - url = instance.get_absolute_url() - data['web_url'] = url - else: - url = None # pragma: no cover - - response = { - label: data - } - - if url is not None: - response['url'] = url - - return response + return {label: instance.format_matched_response()} def scan(self, barcode_data): """Scan a barcode against this plugin.