PO barcode- add line (#5949)

* Enable custom barcode support for 'manufacturerpart' model

* Adds API endpoint for scanning parts into a purchase order

* Update API version

* Activate API endpoint

* Refactor 'format_matched_response'

- Move to instance level
- Use existing mixin class

* Refactor get_supplier_part method

* Fix BarcodePOReceive serializer

* Update API version with link to PR

* Updates to fix existing unit tests

* Fix API version
This commit is contained in:
Oliver 2023-11-21 08:26:32 +11:00 committed by GitHub
parent 264dc9d27a
commit dabd95db85
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 202 additions and 55 deletions

View File

@ -2,11 +2,15 @@
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v151 -> 2023-11-13 : https://github.com/inventree/InvenTree/pull/5906
- Allow user list API to be filtered by user active status - Allow user list API to be filtered by user active status
- Allow owner list API to be filtered by user active status - Allow owner list API to be filtered by user active status

View File

@ -969,6 +969,22 @@ class InvenTreeBarcodeMixin(models.Model):
**kwargs **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 @property
def barcode(self): def barcode(self):
"""Format a minimal barcode string (e.g. for label printing)""" """Format a minimal barcode string (e.g. for label printing)"""

View File

@ -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'),
),
]

View File

@ -387,7 +387,7 @@ class Address(models.Model):
help_text=_('Link to address information (external)')) 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. """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: Attributes:

View File

@ -223,6 +223,7 @@ class ManufacturerPartSerializer(InvenTreeTagModelSerializer):
'description', 'description',
'MPN', 'MPN',
'link', 'link',
'barcode_hash',
'tags', 'tags',
] ]

View File

@ -59,29 +59,12 @@ class BarcodeView(CreateAPIView):
""" """
raise NotImplementedError(f"handle_barcode not implemented for {self.__class__}") 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): Check each loaded plugin, and return the first valid match
"""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
""" """
# Note: the default barcode handlers are loaded (and thus run) first
plugins = registry.with_mixin('barcode') plugins = registry.with_mixin('barcode')
# Look for a barcode plugin which knows how to deal with this 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_data'] = barcode
response['barcode_hash'] = hash_barcode(barcode) response['barcode_hash'] = hash_barcode(barcode)
# A plugin has not been found! return response
if plugin is None:
response['error'] = _('No match found for barcode data')
raise ValidationError(response)
else: class BarcodeScan(BarcodeView):
response['success'] = _('Match found for barcode data') """Endpoint for handling generic barcode scan requests.
return Response(response)
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): 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): class BarcodePOReceive(BarcodeView):
"""Endpoint for handling receiving parts by scanning their barcode. """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 # Receive a purchase order item by scanning its barcode
path("po-receive/", BarcodePOReceive.as_view(), name="api-barcode-po-receive"), 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' # Catch-all performs barcode 'scan'
re_path(r'^.*$', BarcodeScan.as_view(), name='api-barcode-scan'), re_path(r'^.*$', BarcodeScan.as_view(), name='api-barcode-scan'),
] ]

View File

@ -69,6 +69,27 @@ class BarcodeUnassignSerializer(BarcodeAssignMixin):
fields = BarcodeAssignMixin.get_model_fields() 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): class BarcodePOReceiveSerializer(BarcodeSerializer):
"""Serializer for receiving items against a purchase order. """Serializer for receiving items against a purchase order.
@ -80,28 +101,28 @@ class BarcodePOReceiveSerializer(BarcodeSerializer):
purchase_order = serializers.PrimaryKeyRelatedField( purchase_order = serializers.PrimaryKeyRelatedField(
queryset=order.models.PurchaseOrder.objects.all(), queryset=order.models.PurchaseOrder.objects.all(),
required=False, required=False, allow_null=True,
help_text=_('PurchaseOrder to receive items against'), help_text=_('PurchaseOrder to receive items against'),
) )
def validate_purchase_order(self, order: order.models.PurchaseOrder): def validate_purchase_order(self, order: order.models.PurchaseOrder):
"""Validate the provided order""" """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")) raise ValidationError(_("Purchase order has not been placed"))
return order return order
location = serializers.PrimaryKeyRelatedField( location = serializers.PrimaryKeyRelatedField(
queryset=stock.models.StockLocation.objects.all(), queryset=stock.models.StockLocation.objects.all(),
required=False, required=False, allow_null=True,
help_text=_('Location to receive items into'), help_text=_('Location to receive items into'),
) )
def validate_location(self, location: stock.models.StockLocation): def validate_location(self, location: stock.models.StockLocation):
"""Validate the provided location""" """Validate the provided location"""
if location.structural: if location and location.structural:
raise ValidationError(_("Cannot select a structural location")) raise ValidationError(_("Cannot select a structural location"))
return location return location

View File

@ -34,29 +34,8 @@ class InvenTreeInternalBarcodePlugin(BarcodeMixin, InvenTreePlugin):
def format_matched_response(self, label, model, instance): def format_matched_response(self, label, model, instance):
"""Format a response for the scanned data""" """Format a response for the scanned data"""
data = {
'pk': instance.pk
}
# Add in the API URL if available return {label: instance.format_matched_response()}
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
def scan(self, barcode_data): def scan(self, barcode_data):
"""Scan a barcode against this plugin. """Scan a barcode against this plugin.