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 = 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

View File

@ -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)"""

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)'))
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:

View File

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

View File

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

View File

@ -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

View File

@ -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.