mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
264dc9d27a
commit
dabd95db85
@ -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
|
||||||
|
@ -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)"""
|
||||||
|
23
InvenTree/company/migrations/0068_auto_20231120_1108.py
Normal file
23
InvenTree/company/migrations/0068_auto_20231120_1108.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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:
|
||||||
|
@ -223,6 +223,7 @@ class ManufacturerPartSerializer(InvenTreeTagModelSerializer):
|
|||||||
'description',
|
'description',
|
||||||
'MPN',
|
'MPN',
|
||||||
'link',
|
'link',
|
||||||
|
'barcode_hash',
|
||||||
|
|
||||||
'tags',
|
'tags',
|
||||||
]
|
]
|
||||||
|
@ -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'),
|
||||||
]
|
]
|
||||||
|
@ -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
|
||||||
|
@ -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.
|
||||||
|
Loading…
Reference in New Issue
Block a user