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 = 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
|
||||
|
@ -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)"""
|
||||
|
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)'))
|
||||
|
||||
|
||||
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:
|
||||
|
@ -223,6 +223,7 @@ class ManufacturerPartSerializer(InvenTreeTagModelSerializer):
|
||||
'description',
|
||||
'MPN',
|
||||
'link',
|
||||
'barcode_hash',
|
||||
|
||||
'tags',
|
||||
]
|
||||
|
@ -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'),
|
||||
]
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
Loading…
Reference in New Issue
Block a user