2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

[Refactor] Barcode scanning (#8658)

* Enhance SupplierPart barcode mixin

- Provide richer response based on available data

* Add comment

* Add new fields to BarcodePOReceiveSerializer

* Add "supplier" information to serializer

* Add 'is_completed' method for PurchaseOrderLineItem

* Refactor SupplierBarcodeMixin

* Tweak error message

* Logic fix

* Fix response data

* Improved error checking

* Bump API version

* Bump API version

* Error handling

- Improve get_supplier_part method
- Error handling
- Wrap calls to external plugins

* Enhanced unit testing and exception handling

* More exception handling

* Fix circula imports

* Improve unit tests

* Adjust filtering

* Remove outdated tests

* Remove success code for matching item
This commit is contained in:
Oliver 2024-12-17 07:39:49 +11:00 committed by GitHub
parent 169f4f8350
commit d42435c841
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 491 additions and 422 deletions

View File

@ -1,13 +1,16 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 292 INVENTREE_API_VERSION = 293
"""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 = """
v293 - 2024-12-14 : https://github.com/inventree/InvenTree/pull/8658
- Adds new fields to the supplier barcode API endpoints
v292 - 2024-12-03 : https://github.com/inventree/InvenTree/pull/8625 v292 - 2024-12-03 : https://github.com/inventree/InvenTree/pull/8625
- Add "on_order" and "in_stock" annotations to SupplierPart API - Add "on_order" and "in_stock" annotations to SupplierPart API
- Enhanced filtering for the SupplierPart API - Enhanced filtering for the SupplierPart API

View File

@ -11,13 +11,10 @@ from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import rest_framework.views as drfviews import rest_framework.views as drfviews
from error_report.models import Error
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError as DRFValidationError from rest_framework.exceptions import ValidationError as DRFValidationError
from rest_framework.response import Response from rest_framework.response import Response
import InvenTree.sentry
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -34,6 +31,8 @@ def log_error(path, error_name=None, error_info=None, error_data=None):
error_info: The error information (optional, overrides 'info') error_info: The error information (optional, overrides 'info')
error_data: The error data (optional, overrides 'data') error_data: The error data (optional, overrides 'data')
""" """
from error_report.models import Error
kind, info, data = sys.exc_info() kind, info, data = sys.exc_info()
# Check if the error is on the ignore list # Check if the error is on the ignore list
@ -75,6 +74,8 @@ def exception_handler(exc, context):
If sentry error reporting is enabled, we will also provide the original exception to sentry.io If sentry error reporting is enabled, we will also provide the original exception to sentry.io
""" """
import InvenTree.sentry
response = None response = None
# Pass exception to sentry.io handler # Pass exception to sentry.io handler

View File

@ -128,10 +128,18 @@ class PluginValidationMixin(DiffMixin):
Note: Each plugin may raise a ValidationError to prevent deletion. Note: Each plugin may raise a ValidationError to prevent deletion.
""" """
from InvenTree.exceptions import log_error
from plugin.registry import registry from plugin.registry import registry
for plugin in registry.with_mixin('validation'): for plugin in registry.with_mixin('validation'):
try:
plugin.validate_model_deletion(self) plugin.validate_model_deletion(self)
except ValidationError as e:
# Plugin might raise a ValidationError to prevent deletion
raise e
except Exception:
log_error('plugin.validate_model_deletion')
continue
super().delete() super().delete()

View File

@ -71,12 +71,14 @@ def get_icon_packs():
) )
] ]
from InvenTree.exceptions import log_error
from plugin import registry from plugin import registry
for plugin in registry.with_mixin('icon_pack', active=True): for plugin in registry.with_mixin('icon_pack', active=True):
try: try:
icon_packs.extend(plugin.icon_packs()) icon_packs.extend(plugin.icon_packs())
except Exception as e: except Exception as e:
log_error('get_icon_packs')
logger.warning('Error loading icon pack from plugin %s: %s', plugin, e) logger.warning('Error loading icon pack from plugin %s: %s', plugin, e)
_icon_packs = {pack.prefix: pack for pack in icon_packs} _icon_packs = {pack.prefix: pack for pack in icon_packs}

View File

@ -558,6 +558,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
group: bool = True, group: bool = True,
reference: str = '', reference: str = '',
purchase_price=None, purchase_price=None,
destination=None,
): ):
"""Add a new line item to this purchase order. """Add a new line item to this purchase order.
@ -565,12 +566,13 @@ class PurchaseOrder(TotalPriceMixin, Order):
* The supplier part matches the supplier specified for this purchase order * The supplier part matches the supplier specified for this purchase order
* The quantity is greater than zero * The quantity is greater than zero
Args: Arguments:
supplier_part: The supplier_part to add supplier_part: The supplier_part to add
quantity : The number of items to add quantity : The number of items to add
group (bool, optional): If True, this new quantity will be added to an existing line item for the same supplier_part (if it exists). Defaults to True. group (bool, optional): If True, this new quantity will be added to an existing line item for the same supplier_part (if it exists). Defaults to True.
reference (str, optional): Reference to item. Defaults to ''. reference (str, optional): Reference to item. Defaults to ''.
purchase_price (optional): Price of item. Defaults to None. purchase_price (optional): Price of item. Defaults to None.
destination (optional): Destination for item. Defaults to None.
Returns: Returns:
The newly created PurchaseOrderLineItem instance The newly created PurchaseOrderLineItem instance
@ -619,6 +621,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
quantity=quantity, quantity=quantity,
reference=reference, reference=reference,
purchase_price=purchase_price, purchase_price=purchase_price,
destination=destination,
) )
line.save() line.save()
@ -1608,6 +1611,10 @@ class PurchaseOrderLineItem(OrderLineItem):
r = self.quantity - self.received r = self.quantity - self.received
return max(r, 0) return max(r, 0)
def is_completed(self) -> bool:
"""Determine if this lien item has been fully received."""
return self.received >= self.quantity
def update_pricing(self): def update_pricing(self):
"""Update pricing information based on the supplier part data.""" """Update pricing information based on the supplier part data."""
if self.part: if self.part:

View File

@ -6,6 +6,7 @@ from rest_framework import permissions, serializers
from rest_framework.generics import GenericAPIView from rest_framework.generics import GenericAPIView
from rest_framework.response import Response from rest_framework.response import Response
from InvenTree.exceptions import log_error
from plugin import registry from plugin import registry
@ -33,9 +34,12 @@ class ActionPluginView(GenericAPIView):
action_plugins = registry.with_mixin('action') action_plugins = registry.with_mixin('action')
for plugin in action_plugins: for plugin in action_plugins:
try:
if plugin.action_name() == action: if plugin.action_name() == action:
plugin.perform_action(request.user, data=data) plugin.perform_action(request.user, data=data)
return Response(plugin.get_response(request.user, data=data)) return Response(plugin.get_response(request.user, data=data))
except Exception:
log_error('action_plugin')
# If we got to here, no matching action was found # If we got to here, no matching action was found
return Response({'error': _('No matching action found'), 'action': action}) return Response({'error': _('No matching action found'), 'action': action})

View File

@ -151,11 +151,18 @@ class BarcodeView(CreateAPIView):
response = {} response = {}
for current_plugin in plugins: for current_plugin in plugins:
try:
result = current_plugin.scan(barcode) result = current_plugin.scan(barcode)
except Exception:
log_error('BarcodeView.scan_barcode')
continue
if result is None: if result is None:
continue continue
if len(result) == 0:
continue
if 'error' in result: if 'error' in result:
logger.info( logger.info(
'%s.scan(...) returned an error: %s', '%s.scan(...) returned an error: %s',
@ -166,6 +173,7 @@ class BarcodeView(CreateAPIView):
plugin = current_plugin plugin = current_plugin
response = result response = result
else: else:
# Return the first successful match
plugin = current_plugin plugin = current_plugin
response = result response = result
break break
@ -280,6 +288,8 @@ class BarcodeAssign(BarcodeView):
result['plugin'] = inventree_barcode_plugin.name result['plugin'] = inventree_barcode_plugin.name
result['barcode_data'] = barcode result['barcode_data'] = barcode
result.pop('success', None)
raise ValidationError(result) raise ValidationError(result)
barcode_hash = hash_barcode(barcode) barcode_hash = hash_barcode(barcode)
@ -497,8 +507,11 @@ class BarcodePOReceive(BarcodeView):
logger.debug("BarcodePOReceive: scanned barcode - '%s'", barcode) logger.debug("BarcodePOReceive: scanned barcode - '%s'", barcode)
# Extract optional fields from the dataset # Extract optional fields from the dataset
supplier = kwargs.get('supplier')
purchase_order = kwargs.get('purchase_order') purchase_order = kwargs.get('purchase_order')
location = kwargs.get('location') location = kwargs.get('location')
line_item = kwargs.get('line_item')
auto_allocate = kwargs.get('auto_allocate', True)
# Extract location from PurchaseOrder, if available # Extract location from PurchaseOrder, if available
if not location and purchase_order: if not location and purchase_order:
@ -532,9 +545,19 @@ class BarcodePOReceive(BarcodeView):
plugin_response = None plugin_response = None
for current_plugin in plugins: for current_plugin in plugins:
try:
result = current_plugin.scan_receive_item( result = current_plugin.scan_receive_item(
barcode, request.user, purchase_order=purchase_order, location=location barcode,
request.user,
supplier=supplier,
purchase_order=purchase_order,
location=location,
line_item=line_item,
auto_allocate=auto_allocate,
) )
except Exception:
log_error('BarcodePOReceive.handle_barcode')
continue
if result is None: if result is None:
continue continue
@ -560,7 +583,7 @@ class BarcodePOReceive(BarcodeView):
# A plugin has not been found! # A plugin has not been found!
if plugin is None: if plugin is None:
response['error'] = _('No match for supplier barcode') response['error'] = _('No plugin match for supplier barcode')
self.log_scan(request, response, 'success' in response) self.log_scan(request, response, 'success' in response)

View File

@ -3,17 +3,17 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from decimal import Decimal, InvalidOperation
from django.contrib.auth.models import User from django.core.exceptions import ValidationError
from django.db.models import F, Q from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from company.models import Company, SupplierPart from company.models import Company, ManufacturerPart, SupplierPart
from InvenTree.exceptions import log_error
from InvenTree.models import InvenTreeBarcodeMixin from InvenTree.models import InvenTreeBarcodeMixin
from order.models import PurchaseOrder, PurchaseOrderStatus from order.models import PurchaseOrder
from part.models import Part
from plugin.base.integration.SettingsMixin import SettingsMixin from plugin.base.integration.SettingsMixin import SettingsMixin
from stock.models import StockLocation
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -112,6 +112,11 @@ class SupplierBarcodeMixin(BarcodeMixin):
return fields.get(key, backup_value) return fields.get(key, backup_value)
def get_part(self) -> Part | None:
"""Extract the Part object from the barcode fields."""
# TODO: Implement this
return None
@property @property
def quantity(self): def quantity(self):
"""Return the quantity from the barcode fields.""" """Return the quantity from the barcode fields."""
@ -122,11 +127,81 @@ class SupplierBarcodeMixin(BarcodeMixin):
"""Return the supplier part number from the barcode fields.""" """Return the supplier part number from the barcode fields."""
return self.get_field_value(self.SUPPLIER_PART_NUMBER) return self.get_field_value(self.SUPPLIER_PART_NUMBER)
def get_supplier_part(self) -> SupplierPart | None:
"""Return the SupplierPart object for the scanned barcode.
Returns:
SupplierPart object or None
- Filter by the Supplier ID associated with the plugin
- Filter by SKU (if available)
- If more than one match is found, filter by MPN (if available)
"""
sku = self.supplier_part_number
mpn = self.manufacturer_part_number
# Require at least SKU or MPN for lookup
if not sku and not mpn:
return None
supplier_parts = SupplierPart.objects.all()
# Filter by supplier
if supplier := self.get_supplier(cache=True):
supplier_parts = supplier_parts.filter(supplier=supplier)
if sku:
supplier_parts = supplier_parts.filter(SKU=sku)
# Attempt additional filtering by MPN if multiple matches are found
if mpn and supplier_parts.count() > 1:
manufacturer_parts = ManufacturerPart.objects.filter(MPN=mpn)
if manufacturer_parts.count() > 0:
supplier_parts = supplier_parts.filter(
manufacturer_part__in=manufacturer_parts
)
# Requires a unique match
if len(supplier_parts) == 1:
return supplier_parts.first()
@property @property
def manufacturer_part_number(self): def manufacturer_part_number(self):
"""Return the manufacturer part number from the barcode fields.""" """Return the manufacturer part number from the barcode fields."""
return self.get_field_value(self.MANUFACTURER_PART_NUMBER) return self.get_field_value(self.MANUFACTURER_PART_NUMBER)
def get_manufacturer_part(self) -> ManufacturerPart | None:
"""Return the ManufacturerPart object for the scanned barcode.
Returns:
ManufacturerPart object or None
"""
mpn = self.manufacturer_part_number
if not mpn:
return None
parts = ManufacturerPart.objects.filter(MPN=mpn)
if supplier := self.get_supplier(cache=True):
# Manufacturer part must be associated with the supplier
# Case 1: Manufactured by this supplier
q1 = Q(manufacturer=supplier)
# Case 2: Supplied by this supplier
m = (
SupplierPart.objects.filter(supplier=supplier)
.values_list('manufacturer_part', flat=True)
.distinct()
)
q2 = Q(pk__in=m)
parts = parts.filter(q1 | q2).distinct()
# Requires a unique match
if len(parts) == 1:
return parts.first()
@property @property
def customer_order_number(self): def customer_order_number(self):
"""Return the customer order number from the barcode fields.""" """Return the customer order number from the barcode fields."""
@ -137,7 +212,38 @@ class SupplierBarcodeMixin(BarcodeMixin):
"""Return the supplier order number from the barcode fields.""" """Return the supplier order number from the barcode fields."""
return self.get_field_value(self.SUPPLIER_ORDER_NUMBER) return self.get_field_value(self.SUPPLIER_ORDER_NUMBER)
def extract_barcode_fields(self, barcode_data) -> dict[str, str]: def get_purchase_order(self) -> PurchaseOrder | None:
"""Extract the PurchaseOrder object from the barcode fields.
Inspect the customer_order_number and supplier_order_number fields,
and try to find a matching PurchaseOrder object.
Returns:
PurchaseOrder object or None
"""
customer_order_number = self.customer_order_number
supplier_order_number = self.supplier_order_number
if not (customer_order_number or supplier_order_number):
return None
# First, attempt lookup based on the customer_order_number
if customer_order_number:
orders = PurchaseOrder.objects.filter(reference=customer_order_number)
elif supplier_order_number:
orders = PurchaseOrder.objects.filter(
supplier_reference=supplier_order_number
)
if supplier := self.get_supplier(cache=True):
orders = orders.filter(supplier=supplier)
# Requires a unique match
if len(orders) == 1:
return orders.first()
def extract_barcode_fields(self, barcode_data: str) -> dict[str, str]:
"""Method to extract barcode fields from barcode data. """Method to extract barcode fields from barcode data.
This method should return a dict object where the keys are the field names, This method should return a dict object where the keys are the field names,
@ -153,93 +259,177 @@ class SupplierBarcodeMixin(BarcodeMixin):
'extract_barcode_fields must be implemented by each plugin' 'extract_barcode_fields must be implemented by each plugin'
) )
def scan(self, barcode_data): def scan(self, barcode_data: str) -> dict:
"""Try to match a supplier barcode to a supplier part.""" """Perform a generic 'scan' operation on a supplier barcode.
The supplier barcode may provide sufficient information to match against
one of the following model types:
- SupplierPart
- ManufacturerPart
- PurchaseOrder
- PurchaseOrderLineItem (todo)
- StockItem (todo)
- Part (todo)
If any matches are made, return a dict object containing the relevant information.
"""
barcode_data = str(barcode_data).strip() barcode_data = str(barcode_data).strip()
self.barcode_fields = self.extract_barcode_fields(barcode_data) self.barcode_fields = self.extract_barcode_fields(barcode_data)
if self.supplier_part_number is None and self.manufacturer_part_number is None: # Generate possible matches for this barcode
return None # Note: Each of these functions can be overridden by the plugin (if necessary)
matches = {
supplier_parts = self.get_supplier_parts( Part.barcode_model_type(): self.get_part(),
sku=self.supplier_part_number, PurchaseOrder.barcode_model_type(): self.get_purchase_order(),
mpn=self.manufacturer_part_number, SupplierPart.barcode_model_type(): self.get_supplier_part(),
supplier=self.get_supplier(), ManufacturerPart.barcode_model_type(): self.get_manufacturer_part(),
)
if len(supplier_parts) > 1:
return {'error': _('Found multiple matching supplier parts for barcode')}
elif not supplier_parts:
return None
supplier_part = supplier_parts[0]
data = {
'pk': supplier_part.pk,
'api_url': f'{SupplierPart.get_api_url()}{supplier_part.pk}/',
'web_url': supplier_part.get_absolute_url(),
} }
return {SupplierPart.barcode_model_type(): data} data = {}
def scan_receive_item(self, barcode_data, user, purchase_order=None, location=None): # At least one matching item was found
"""Try to scan a supplier barcode to receive a purchase order item.""" has_match = False
barcode_data = str(barcode_data).strip()
self.barcode_fields = self.extract_barcode_fields(barcode_data) for k, v in matches.items():
if v and hasattr(v, 'pk'):
has_match = True
data[k] = v.format_matched_response()
if self.supplier_part_number is None and self.manufacturer_part_number is None: if not has_match:
return None return None
supplier = self.get_supplier() # Add in supplier information (if available)
if supplier := self.get_supplier():
data['company'] = {'pk': supplier.pk}
supplier_parts = self.get_supplier_parts( data['success'] = _('Found matching item')
sku=self.supplier_part_number,
mpn=self.manufacturer_part_number,
supplier=supplier,
)
if len(supplier_parts) > 1: return data
return {'error': _('Found multiple matching supplier parts for barcode')}
elif not supplier_parts:
return None
supplier_part = supplier_parts[0] def scan_receive_item(
self,
# If a purchase order is not provided, extract it from the provided data barcode_data: str,
if not purchase_order:
matching_orders = self.get_purchase_orders(
self.customer_order_number,
self.supplier_order_number,
supplier=supplier,
)
order = self.customer_order_number or self.supplier_order_number
if len(matching_orders) > 1:
return {
'error': _(f"Found multiple purchase orders matching '{order}'")
}
if len(matching_orders) == 0:
return {'error': _(f"No matching purchase order for '{order}'")}
purchase_order = matching_orders.first()
if supplier and purchase_order and purchase_order.supplier != supplier:
return {'error': _('Purchase order does not match supplier')}
return self.receive_purchase_order_item(
supplier_part,
user, user,
quantity=self.quantity, supplier=None,
purchase_order=purchase_order, line_item=None,
location=location, purchase_order=None,
barcode=barcode_data, location=None,
auto_allocate: bool = True,
**kwargs,
) -> dict | None:
"""Attempt to receive an item against a PurchaseOrder via barcode scanning.
Arguments:
barcode_data: The raw barcode data
user: The User performing the action
supplier: The Company object to receive against (or None)
purchase_order: The PurchaseOrder object to receive against (or None)
line_item: The PurchaseOrderLineItem object to receive against (or None)
location: The StockLocation object to receive into (or None)
auto_allocate: If True, automatically receive the item (if possible)
Returns:
A dict object containing the result of the action.
The more "context" data that can be provided, the better the chances of a successful match.
"""
barcode_data = str(barcode_data).strip()
self.barcode_fields = self.extract_barcode_fields(barcode_data)
# Extract supplier information
supplier = supplier or self.get_supplier(cache=True)
if not supplier:
# No supplier information available
return None
# Extract purchase order information
purchase_order = purchase_order or self.get_purchase_order()
if not purchase_order or purchase_order.supplier != supplier:
# Purchase order does not match supplier
return None
supplier_part = self.get_supplier_part()
if not supplier_part:
# No supplier part information available
return None
# Attempt to find matching line item
if not line_item:
line_items = purchase_order.lines.filter(part=supplier_part)
if line_items.count() == 1:
line_item = line_items.first()
if not line_item:
# No line item information available
return None
if line_item.part != supplier_part:
return {'error': _('Supplier part does not match line item')}
if line_item.is_completed():
return {'error': _('Line item is already completed')}
# Extract location information for the line item
location = (
location
or line_item.destination
or purchase_order.destination
or line_item.part.part.get_default_location()
) )
def get_supplier(self) -> Company | None: # Extract quantity information
quantity = self.quantity
# At this stage, we *should* have enough information to attempt to receive the item
# If auto_allocate is True, attempt to receive the item automatically
# Otherwise, return the required information to the client
action_required = not auto_allocate or location is None or quantity is None
if quantity is None:
quantity = line_item.remaining()
quantity = float(quantity)
# Construct a response object
response = {
'lineitem': {
'pk': line_item.pk,
'quantity': quantity,
'supplier_part': supplier_part.pk,
'purchase_order': purchase_order.pk,
'location': location.pk if location else None,
}
}
if action_required:
# Further information is required to receive the item
response['action_required'] = _(
'Further information required to receive line item'
)
else:
# Use the information we have to attempt to receive the item into stock
try:
purchase_order.receive_line_item(
line_item, location, quantity, user, barcode=barcode_data
)
response['success'] = _('Received purchase order line item')
except ValidationError as e:
# Pass a ValidationError back to the client
response['error'] = e.message
except Exception:
# Handle any other exceptions
log_error('scan_receive_item')
response['error'] = _('Failed to receive line item')
return response
def get_supplier(self, cache: bool = False) -> Company | None:
"""Get the supplier for the SUPPLIER_ID set in the plugin settings. """Get the supplier for the SUPPLIER_ID set in the plugin settings.
If it's not defined, try to guess it and set it if possible. If it's not defined, try to guess it and set it if possible.
@ -247,29 +437,32 @@ class SupplierBarcodeMixin(BarcodeMixin):
if not isinstance(self, SettingsMixin): if not isinstance(self, SettingsMixin):
return None return None
def _cache_supplier(supplier):
"""Cache and return the supplier object."""
if cache:
self._supplier = supplier
return supplier
# Cache the supplier object, so we don't have to look it up every time
if cache and hasattr(self, '_supplier'):
return self._supplier
if supplier_pk := self.get_setting('SUPPLIER_ID'): if supplier_pk := self.get_setting('SUPPLIER_ID'):
try: return _cache_supplier(Company.objects.filter(pk=supplier_pk).first())
return Company.objects.get(pk=supplier_pk)
except Company.DoesNotExist:
logger.error(
'No company with pk %d (set "SUPPLIER_ID" setting to a valid value)',
supplier_pk,
)
return None
if not (supplier_name := getattr(self, 'DEFAULT_SUPPLIER_NAME', None)): if not (supplier_name := getattr(self, 'DEFAULT_SUPPLIER_NAME', None)):
return None return _cache_supplier(None)
suppliers = Company.objects.filter( suppliers = Company.objects.filter(
name__icontains=supplier_name, is_supplier=True name__icontains=supplier_name, is_supplier=True
) )
if len(suppliers) != 1: if len(suppliers) != 1:
return None return _cache_supplier(None)
self.set_setting('SUPPLIER_ID', suppliers.first().pk) self.set_setting('SUPPLIER_ID', suppliers.first().pk)
return suppliers.first() return _cache_supplier(suppliers.first())
@classmethod @classmethod
def ecia_field_map(cls): def ecia_field_map(cls):
@ -358,150 +551,3 @@ class SupplierBarcodeMixin(BarcodeMixin):
return SupplierBarcodeMixin.split_fields( return SupplierBarcodeMixin.split_fields(
barcode_data, delimiter=DELIMITER, header=HEADER, trailer=TRAILER barcode_data, delimiter=DELIMITER, header=HEADER, trailer=TRAILER
) )
@staticmethod
def get_purchase_orders(
customer_order_number, supplier_order_number, supplier: Company = None
):
"""Attempt to find a purchase order from the extracted customer and supplier order numbers."""
orders = PurchaseOrder.objects.filter(status=PurchaseOrderStatus.PLACED.value)
if supplier:
orders = orders.filter(supplier=supplier)
# this works because reference and supplier_reference are not nullable, so if
# customer_order_number or supplier_order_number is None, the query won't return anything
reference_filter = Q(reference__iexact=customer_order_number)
supplier_reference_filter = Q(supplier_reference__iexact=supplier_order_number)
orders_union = orders.filter(reference_filter | supplier_reference_filter)
if orders_union.count() == 1:
return orders_union
else:
orders_intersection = orders.filter(
reference_filter & supplier_reference_filter
)
return orders_intersection if orders_intersection else orders_union
@staticmethod
def get_supplier_parts(
sku: str | None = None, supplier: Company = None, mpn: str | None = None
):
"""Get a supplier part from SKU or by supplier and MPN."""
if not (sku or supplier or mpn):
return SupplierPart.objects.none()
supplier_parts = SupplierPart.objects.all()
if sku:
supplier_parts = supplier_parts.filter(SKU__iexact=sku)
if len(supplier_parts) == 1:
return supplier_parts
if supplier:
supplier_parts = supplier_parts.filter(supplier=supplier.pk)
if len(supplier_parts) == 1:
return supplier_parts
if mpn:
supplier_parts = supplier_parts.filter(manufacturer_part__MPN__iexact=mpn)
if len(supplier_parts) == 1:
return supplier_parts
logger.warning(
"Found %d supplier parts for SKU '%s', supplier '%s', MPN '%s'",
supplier_parts.count(),
sku,
supplier.name if supplier else None,
mpn,
)
return supplier_parts
@staticmethod
def receive_purchase_order_item(
supplier_part: SupplierPart,
user: User,
quantity: Decimal | str | None = None,
purchase_order: PurchaseOrder = None,
location: StockLocation = None,
barcode: str | None = None,
) -> dict:
"""Try to receive a purchase order item.
Returns:
A dict object containing:
- on success: a "success" message
- on partial success: the "lineitem" with quantity and location (both can be None)
- on failure: an "error" message
"""
if quantity:
try:
quantity = Decimal(quantity)
except InvalidOperation:
logger.warning("Failed to parse quantity '%s'", quantity)
quantity = None
# find incomplete line_items that match the supplier_part
line_items = purchase_order.lines.filter(
part=supplier_part.pk, quantity__gt=F('received')
)
if len(line_items) == 1 or not quantity:
line_item = line_items[0]
else:
# if there are multiple line items and the barcode contains a quantity:
# 1. return the first line_item where line_item.quantity == quantity
# 2. return the first line_item where line_item.quantity > quantity
# 3. return the first line_item
for line_item in line_items:
if line_item.quantity == quantity:
break
else:
for line_item in line_items:
if line_item.quantity > quantity:
break
else:
line_item = line_items.first()
if not line_item:
return {'error': _('Failed to find pending line item for supplier part')}
no_stock_locations = False
if not location:
# try to guess the destination were the stock_part should go
# 1. check if it's defined on the line_item
# 2. check if it's defined on the part
# 3. check if there's 1 or 0 stock locations defined in InvenTree
# -> assume all stock is going into that location (or no location)
if (location := line_item.destination) or (
location := supplier_part.part.get_default_location()
):
pass
elif StockLocation.objects.count() <= 1:
if not (location := StockLocation.objects.first()):
no_stock_locations = True
response = {
'lineitem': {'pk': line_item.pk, 'purchase_order': purchase_order.pk}
}
if quantity:
response['lineitem']['quantity'] = quantity
if location:
response['lineitem']['location'] = location.pk
# if either the quantity is missing or no location is defined/found
# -> return the line_item found, so the client can gather the missing
# information and complete the action with an 'api-po-receive' call
if not quantity or (not location and not no_stock_locations):
response['action_required'] = _(
'Further information required to receive line item'
)
return response
purchase_order.receive_line_item(
line_item, location, quantity, user, barcode=barcode
)
response['success'] = _('Received purchase order line item')
return response

View File

@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
import common.models import common.models
import company.models
import order.models import order.models
import plugin.base.barcodes.helper import plugin.base.barcodes.helper
import stock.models import stock.models
@ -149,6 +150,13 @@ class BarcodePOReceiveSerializer(BarcodeSerializer):
- location: Location to receive items into - location: Location to receive items into
""" """
supplier = serializers.PrimaryKeyRelatedField(
queryset=company.models.Company.objects.all(),
required=False,
allow_null=True,
help_text=_('Supplier to receive items from'),
)
purchase_order = serializers.PrimaryKeyRelatedField( purchase_order = serializers.PrimaryKeyRelatedField(
queryset=order.models.PurchaseOrder.objects.all(), queryset=order.models.PurchaseOrder.objects.all(),
required=False, required=False,
@ -177,6 +185,19 @@ class BarcodePOReceiveSerializer(BarcodeSerializer):
return location return location
line_item = serializers.PrimaryKeyRelatedField(
queryset=order.models.PurchaseOrderLineItem.objects.all(),
required=False,
allow_null=True,
help_text=_('Purchase order line item to receive items against'),
)
auto_allocate = serializers.BooleanField(
required=False,
default=True,
help_text=_('Automatically allocate stock items to the purchase order'),
)
class BarcodeSOAllocateSerializer(BarcodeSerializer): class BarcodeSOAllocateSerializer(BarcodeSerializer):
"""Serializr for allocating stock items to a sales order. """Serializr for allocating stock items to a sales order.

View File

@ -10,8 +10,6 @@ from InvenTree.unit_test import InvenTreeAPITestCase
from part.models import Part from part.models import Part
from stock.models import StockItem from stock.models import StockItem
from .mixins import SupplierBarcodeMixin
class BarcodeAPITest(InvenTreeAPITestCase): class BarcodeAPITest(InvenTreeAPITestCase):
"""Tests for barcode api.""" """Tests for barcode api."""
@ -390,142 +388,3 @@ class SOAllocateTest(InvenTreeAPITestCase):
self.line_item.refresh_from_db() self.line_item.refresh_from_db()
self.assertEqual(self.line_item.allocated_quantity(), 10) self.assertEqual(self.line_item.allocated_quantity(), 10)
self.assertTrue(self.line_item.is_fully_allocated()) self.assertTrue(self.line_item.is_fully_allocated())
class SupplierBarcodeMixinTest(InvenTreeAPITestCase):
"""Unit tests for the SupplierBarcodeMixin class."""
@classmethod
def setUpTestData(cls):
"""Setup for all tests."""
super().setUpTestData()
cls.supplier = company.models.Company.objects.create(
name='Supplier Barcode Mixin Test Company', is_supplier=True
)
cls.supplier_other = company.models.Company.objects.create(
name='Other Supplier Barcode Mixin Test Company', is_supplier=True
)
cls.supplier_no_orders = company.models.Company.objects.create(
name='Supplier Barcode Mixin Test Company with no Orders', is_supplier=True
)
cls.purchase_order_pending = order.models.PurchaseOrder.objects.create(
status=order.models.PurchaseOrderStatus.PENDING.value,
supplier=cls.supplier,
supplier_reference='ORDER#1337',
)
cls.purchase_order_1 = order.models.PurchaseOrder.objects.create(
status=order.models.PurchaseOrderStatus.PLACED.value,
supplier=cls.supplier,
supplier_reference='ORDER#1338',
)
cls.purchase_order_duplicate_1 = order.models.PurchaseOrder.objects.create(
status=order.models.PurchaseOrderStatus.PLACED.value,
supplier=cls.supplier,
supplier_reference='ORDER#1339',
)
cls.purchase_order_duplicate_2 = order.models.PurchaseOrder.objects.create(
status=order.models.PurchaseOrderStatus.PLACED.value,
supplier=cls.supplier_other,
supplier_reference='ORDER#1339',
)
def setUp(self):
"""Setup method for each test."""
super().setUp()
def test_order_not_placed(self):
"""Check that purchase order which has not been placed doesn't get returned."""
purchase_orders = SupplierBarcodeMixin.get_purchase_orders(
self.purchase_order_pending.reference, None
)
self.assertIsNone(purchase_orders.first())
purchase_orders = SupplierBarcodeMixin.get_purchase_orders(
None, self.purchase_order_pending.supplier_reference
)
self.assertIsNone(purchase_orders.first())
def test_order_simple(self):
"""Check that we can get a purchase order by either reference, supplier_reference or both."""
purchase_orders = SupplierBarcodeMixin.get_purchase_orders(
self.purchase_order_1.reference, None
)
self.assertEqual(purchase_orders.count(), 1)
self.assertEqual(purchase_orders.first(), self.purchase_order_1)
purchase_orders = SupplierBarcodeMixin.get_purchase_orders(
None, self.purchase_order_1.supplier_reference
)
self.assertEqual(purchase_orders.count(), 1)
self.assertEqual(purchase_orders.first(), self.purchase_order_1)
purchase_orders = SupplierBarcodeMixin.get_purchase_orders(
self.purchase_order_1.reference, self.purchase_order_1.supplier_reference
)
self.assertEqual(purchase_orders.count(), 1)
self.assertEqual(purchase_orders.first(), self.purchase_order_1)
purchase_orders = SupplierBarcodeMixin.get_purchase_orders(
self.purchase_order_1.reference,
self.purchase_order_1.supplier_reference,
supplier=self.supplier,
)
self.assertEqual(purchase_orders.count(), 1)
self.assertEqual(purchase_orders.first(), self.purchase_order_1)
def test_wrong_supplier_order(self):
"""Check that no orders get returned if the wrong supplier is specified."""
purchase_orders = SupplierBarcodeMixin.get_purchase_orders(
self.purchase_order_1.reference, None, supplier=self.supplier_no_orders
)
self.assertIsNone(purchase_orders.first())
purchase_orders = SupplierBarcodeMixin.get_purchase_orders(
None,
self.purchase_order_1.supplier_reference,
supplier=self.supplier_no_orders,
)
self.assertIsNone(purchase_orders.first())
def test_supplier_order_duplicate(self):
"""Test getting purchase_orders with the same supplier_reference works correctly."""
purchase_orders = SupplierBarcodeMixin.get_purchase_orders(
None, self.purchase_order_duplicate_1.supplier_reference
)
self.assertEqual(purchase_orders.count(), 2)
self.assertEqual(
set(purchase_orders),
{self.purchase_order_duplicate_1, self.purchase_order_duplicate_2},
)
purchase_orders = SupplierBarcodeMixin.get_purchase_orders(
self.purchase_order_duplicate_1.reference,
self.purchase_order_duplicate_1.supplier_reference,
)
self.assertEqual(purchase_orders.count(), 1)
self.assertEqual(purchase_orders.first(), self.purchase_order_duplicate_1)
# check that mixing the reference and supplier_reference doesn't work
purchase_orders = SupplierBarcodeMixin.get_purchase_orders(
self.purchase_order_duplicate_1.supplier_reference,
self.purchase_order_duplicate_1.reference,
)
self.assertIsNone(purchase_orders.first())
# check that specifying the right supplier works
purchase_orders = SupplierBarcodeMixin.get_purchase_orders(
None,
self.purchase_order_duplicate_1.supplier_reference,
supplier=self.supplier_other,
)
self.assertEqual(purchase_orders.count(), 1)
self.assertEqual(purchase_orders.first(), self.purchase_order_duplicate_2)

View File

@ -1,10 +1,11 @@
"""API for location plugins.""" """API for location plugins."""
from rest_framework import permissions, serializers from rest_framework import permissions, serializers
from rest_framework.exceptions import NotFound, ParseError from rest_framework.exceptions import NotFound, ParseError, ValidationError
from rest_framework.generics import GenericAPIView from rest_framework.generics import GenericAPIView
from rest_framework.response import Response from rest_framework.response import Response
from InvenTree.exceptions import log_error
from InvenTree.tasks import offload_task from InvenTree.tasks import offload_task
from plugin.registry import call_plugin_function, registry from plugin.registry import call_plugin_function, registry
from stock.models import StockItem, StockLocation from stock.models import StockItem, StockLocation
@ -72,6 +73,9 @@ class LocatePluginView(GenericAPIView):
except (ValueError, StockItem.DoesNotExist): except (ValueError, StockItem.DoesNotExist):
raise NotFound(f"StockItem matching PK '{item_pk}' not found") raise NotFound(f"StockItem matching PK '{item_pk}' not found")
except Exception:
log_error('locate_stock_item')
return ValidationError('Error locating stock item')
elif location_pk: elif location_pk:
try: try:
@ -91,6 +95,8 @@ class LocatePluginView(GenericAPIView):
except (ValueError, StockLocation.DoesNotExist): except (ValueError, StockLocation.DoesNotExist):
raise NotFound(f"StockLocation matching PK '{location_pk}' not found") raise NotFound(f"StockLocation matching PK '{location_pk}' not found")
except Exception:
log_error('locate_stock_location')
return ValidationError('Error locating stock location')
else: else:
raise ParseError("Must supply either 'item' or 'location' parameter") raise ParseError("Must supply either 'item' or 'location' parameter")

View File

@ -98,6 +98,8 @@ class InvenTreeInternalBarcodePlugin(SettingsMixin, BarcodeMixin, InvenTreePlugi
supported_models = plugin.base.barcodes.helper.get_supported_barcode_models() supported_models = plugin.base.barcodes.helper.get_supported_barcode_models()
succcess_message = _('Found matching item')
if barcode_dict is not None and type(barcode_dict) is dict: if barcode_dict is not None and type(barcode_dict) is dict:
# Look for various matches. First good match will be returned # Look for various matches. First good match will be returned
for model in supported_models: for model in supported_models:
@ -107,7 +109,11 @@ class InvenTreeInternalBarcodePlugin(SettingsMixin, BarcodeMixin, InvenTreePlugi
try: try:
pk = int(barcode_dict[label]) pk = int(barcode_dict[label])
instance = model.objects.get(pk=pk) instance = model.objects.get(pk=pk)
return self.format_matched_response(label, model, instance)
return {
**self.format_matched_response(label, model, instance),
'success': succcess_message,
}
except (ValueError, model.DoesNotExist): except (ValueError, model.DoesNotExist):
pass pass
@ -122,7 +128,10 @@ class InvenTreeInternalBarcodePlugin(SettingsMixin, BarcodeMixin, InvenTreePlugi
instance = model.lookup_barcode(barcode_hash) instance = model.lookup_barcode(barcode_hash)
if instance is not None: if instance is not None:
return self.format_matched_response(label, model, instance) return {
**self.format_matched_response(label, model, instance),
'success': succcess_message,
}
def generate(self, model_instance: InvenTreeBarcodeMixin): def generate(self, model_instance: InvenTreeBarcodeMixin):
"""Generate a barcode for a given model instance.""" """Generate a barcode for a given model instance."""

View File

@ -6,6 +6,7 @@ from company.models import Company, ManufacturerPart, SupplierPart
from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.unit_test import InvenTreeAPITestCase
from order.models import PurchaseOrder, PurchaseOrderLineItem from order.models import PurchaseOrder, PurchaseOrderLineItem
from part.models import Part from part.models import Part
from plugin.registry import registry
from stock.models import StockItem, StockLocation from stock.models import StockItem, StockLocation
@ -32,19 +33,33 @@ class SupplierBarcodeTests(InvenTreeAPITestCase):
part=part, manufacturer=manufacturer, MPN='LDK320ADU33R' part=part, manufacturer=manufacturer, MPN='LDK320ADU33R'
) )
supplier = Company.objects.create(name='Supplier', is_supplier=True) digikey_supplier = Company.objects.create(name='Supplier', is_supplier=True)
mouser = Company.objects.create(name='Mouser Test', is_supplier=True) mouser_supplier = Company.objects.create(name='Mouser Test', is_supplier=True)
supplier_parts = [ supplier_parts = [
SupplierPart(SKU='296-LM358BIDDFRCT-ND', part=part, supplier=supplier), SupplierPart(
SupplierPart(SKU='1', part=part, manufacturer_part=mpart1, supplier=mouser), SKU='296-LM358BIDDFRCT-ND', part=part, supplier=digikey_supplier
SupplierPart(SKU='2', part=part, manufacturer_part=mpart2, supplier=mouser), ),
SupplierPart(SKU='C312270', part=part, supplier=supplier), SupplierPart(SKU='C312270', part=part, supplier=digikey_supplier),
SupplierPart(SKU='WBP-302', part=part, supplier=supplier), SupplierPart(SKU='WBP-302', part=part, supplier=digikey_supplier),
SupplierPart(
SKU='1', part=part, manufacturer_part=mpart1, supplier=mouser_supplier
),
SupplierPart(
SKU='2', part=part, manufacturer_part=mpart2, supplier=mouser_supplier
),
] ]
SupplierPart.objects.bulk_create(supplier_parts) SupplierPart.objects.bulk_create(supplier_parts)
# Assign supplier information to the plugins
# Add supplier information to each custom plugin
digikey_plugin = registry.get_plugin('digikeyplugin')
digikey_plugin.set_setting('SUPPLIER_ID', digikey_supplier.pk)
mouser_plugin = registry.get_plugin('mouserplugin')
mouser_plugin.set_setting('SUPPLIER_ID', mouser_supplier.pk)
def test_digikey_barcode(self): def test_digikey_barcode(self):
"""Test digikey barcode.""" """Test digikey barcode."""
result = self.post( result = self.post(
@ -63,6 +78,7 @@ class SupplierBarcodeTests(InvenTreeAPITestCase):
result = self.post( result = self.post(
self.SCAN_URL, data={'barcode': DIGIKEY_BARCODE_2}, expected_code=200 self.SCAN_URL, data={'barcode': DIGIKEY_BARCODE_2}, expected_code=200
) )
self.assertEqual(result.data['plugin'], 'DigiKeyPlugin') self.assertEqual(result.data['plugin'], 'DigiKeyPlugin')
supplier_part_data = result.data.get('supplierpart') supplier_part_data = result.data.get('supplierpart')
@ -147,8 +163,11 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
"""Create supplier part and purchase_order.""" """Create supplier part and purchase_order."""
super().setUp() super().setUp()
self.loc_1 = StockLocation.objects.create(name='Location 1')
self.loc_2 = StockLocation.objects.create(name='Location 2')
part = Part.objects.create(name='Test Part', description='Test Part') part = Part.objects.create(name='Test Part', description='Test Part')
supplier = Company.objects.create(name='Supplier', is_supplier=True) digikey_supplier = Company.objects.create(name='Supplier', is_supplier=True)
manufacturer = Company.objects.create( manufacturer = Company.objects.create(
name='Test Manufacturer', is_manufacturer=True name='Test Manufacturer', is_manufacturer=True
) )
@ -159,23 +178,29 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
) )
self.purchase_order1 = PurchaseOrder.objects.create( self.purchase_order1 = PurchaseOrder.objects.create(
supplier_reference='72991337', supplier=supplier supplier_reference='72991337',
supplier=digikey_supplier,
destination=self.loc_1,
) )
supplier_parts1 = [ supplier_parts1 = [
SupplierPart(SKU=f'1_{i}', part=part, supplier=supplier) for i in range(6) SupplierPart(SKU=f'1_{i}', part=part, supplier=digikey_supplier)
for i in range(6)
] ]
supplier_parts1.insert( supplier_parts1.insert(
2, SupplierPart(SKU='296-LM358BIDDFRCT-ND', part=part, supplier=supplier) 2,
SupplierPart(
SKU='296-LM358BIDDFRCT-ND', part=part, supplier=digikey_supplier
),
) )
for supplier_part in supplier_parts1: for supplier_part in supplier_parts1:
supplier_part.save() supplier_part.save()
self.purchase_order1.add_line_item(supplier_part, 8) self.purchase_order1.add_line_item(supplier_part, 8, destination=self.loc_2)
self.purchase_order2 = PurchaseOrder.objects.create( self.purchase_order2 = PurchaseOrder.objects.create(
reference='P0-1337', supplier=mouser reference='P0-1337', supplier=mouser, destination=self.loc_1
) )
self.purchase_order2.place_order() self.purchase_order2.place_order()
@ -190,42 +215,63 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
for supplier_part in supplier_parts2: for supplier_part in supplier_parts2:
supplier_part.save() supplier_part.save()
self.purchase_order2.add_line_item(supplier_part, 5) self.purchase_order2.add_line_item(supplier_part, 5, destination=self.loc_2)
# Add supplier information to each custom plugin
digikey_plugin = registry.get_plugin('digikeyplugin')
digikey_plugin.set_setting('SUPPLIER_ID', digikey_supplier.pk)
mouser_plugin = registry.get_plugin('mouserplugin')
mouser_plugin.set_setting('SUPPLIER_ID', mouser.pk)
def test_receive(self): def test_receive(self):
"""Test receiving an item from a barcode.""" """Test receiving an item from a barcode."""
url = reverse('api-barcode-po-receive') url = reverse('api-barcode-po-receive')
# First attempt - PO is not yet "placed"
result1 = self.post(url, data={'barcode': DIGIKEY_BARCODE}, expected_code=400) result1 = self.post(url, data={'barcode': DIGIKEY_BARCODE}, expected_code=400)
self.assertTrue(result1.data['error'].startswith('No matching purchase order')) self.assertIn('received against an order marked as', result1.data['error'])
# Next, place the order - receipt should then work
self.purchase_order1.place_order() self.purchase_order1.place_order()
result2 = self.post(url, data={'barcode': DIGIKEY_BARCODE}, expected_code=200) result2 = self.post(url, data={'barcode': DIGIKEY_BARCODE}, expected_code=200)
self.assertIn('success', result2.data) self.assertIn('success', result2.data)
# Attempt to receive the same item again
# Already received - failure expected
result3 = self.post(url, data={'barcode': DIGIKEY_BARCODE}, expected_code=400) result3 = self.post(url, data={'barcode': DIGIKEY_BARCODE}, expected_code=400)
self.assertEqual(result3.data['error'], 'Item has already been received') self.assertEqual(result3.data['error'], 'Item has already been received')
result4 = self.post(
url, data={'barcode': DIGIKEY_BARCODE[:-1]}, expected_code=400
)
self.assertTrue(
result4.data['error'].startswith(
'Failed to find pending line item for supplier part'
)
)
result5 = self.post( result5 = self.post(
reverse('api-barcode-scan'), reverse('api-barcode-scan'),
data={'barcode': DIGIKEY_BARCODE}, data={'barcode': DIGIKEY_BARCODE},
expected_code=200, expected_code=200,
) )
stock_item = StockItem.objects.get(pk=result5.data['stockitem']['pk']) stock_item = StockItem.objects.get(pk=result5.data['stockitem']['pk'])
self.assertEqual(stock_item.supplier_part.SKU, '296-LM358BIDDFRCT-ND') self.assertEqual(stock_item.supplier_part.SKU, '296-LM358BIDDFRCT-ND')
self.assertEqual(stock_item.quantity, 10) self.assertEqual(stock_item.quantity, 10)
self.assertEqual(stock_item.location, None) self.assertEqual(stock_item.location, self.loc_2)
def test_no_auto_allocate(self):
"""Test with auto_allocate explicitly disabled."""
url = reverse('api-barcode-po-receive')
self.purchase_order1.place_order()
response = self.post(
url,
data={'barcode': DIGIKEY_BARCODE, 'auto_allocate': False},
expected_code=200,
)
self.assertEqual(response.data['plugin'], 'DigiKeyPlugin')
self.assertIn('action_required', response.data)
item = response.data['lineitem']
self.assertEqual(item['quantity'], 10.0)
self.assertEqual(item['purchase_order'], self.purchase_order1.pk)
self.assertEqual(item['location'], self.loc_2.pk)
def test_receive_custom_order_number(self): def test_receive_custom_order_number(self):
"""Test receiving an item from a barcode with a custom order number.""" """Test receiving an item from a barcode with a custom order number."""
@ -233,23 +279,32 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
result1 = self.post(url, data={'barcode': MOUSER_BARCODE}, expected_code=200) result1 = self.post(url, data={'barcode': MOUSER_BARCODE}, expected_code=200)
self.assertIn('success', result1.data) self.assertIn('success', result1.data)
# Scan the same barcode again - should be resolved to the created item
result2 = self.post( result2 = self.post(
reverse('api-barcode-scan'), reverse('api-barcode-scan'),
data={'barcode': MOUSER_BARCODE}, data={'barcode': MOUSER_BARCODE},
expected_code=200, expected_code=200,
) )
stock_item = StockItem.objects.get(pk=result2.data['stockitem']['pk']) stock_item = StockItem.objects.get(pk=result2.data['stockitem']['pk'])
self.assertEqual(stock_item.supplier_part.SKU, '42') self.assertEqual(stock_item.supplier_part.SKU, '42')
self.assertEqual(stock_item.supplier_part.manufacturer_part.MPN, 'MC34063ADR') self.assertEqual(stock_item.supplier_part.manufacturer_part.MPN, 'MC34063ADR')
self.assertEqual(stock_item.quantity, 3) self.assertEqual(stock_item.quantity, 3)
self.assertEqual(stock_item.location, None) self.assertEqual(stock_item.location, self.loc_2)
self.assertEqual(stock_item.barcode_data, MOUSER_BARCODE)
def test_receive_one_stock_location(self): def test_receive_stock_location(self):
"""Test receiving an item when only one stock location exists.""" """Test receiving an item when the location is provided."""
stock_location = StockLocation.objects.create(name='Test Location') stock_location = StockLocation.objects.create(name='Test Location')
url = reverse('api-barcode-po-receive') url = reverse('api-barcode-po-receive')
result1 = self.post(url, data={'barcode': MOUSER_BARCODE}, expected_code=200)
result1 = self.post(
url,
data={'barcode': MOUSER_BARCODE, 'location': stock_location.pk},
expected_code=200,
)
self.assertIn('success', result1.data) self.assertIn('success', result1.data)
result2 = self.post( result2 = self.post(
@ -257,6 +312,7 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
data={'barcode': MOUSER_BARCODE}, data={'barcode': MOUSER_BARCODE},
expected_code=200, expected_code=200,
) )
stock_item = StockItem.objects.get(pk=result2.data['stockitem']['pk']) stock_item = StockItem.objects.get(pk=result2.data['stockitem']['pk'])
self.assertEqual(stock_item.location, stock_location) self.assertEqual(stock_item.location, stock_location)
@ -286,6 +342,15 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
StockLocation.objects.create(name='Test Location 1') StockLocation.objects.create(name='Test Location 1')
stock_location2 = StockLocation.objects.create(name='Test Location 2') stock_location2 = StockLocation.objects.create(name='Test Location 2')
# Ensure no other fallback locations are set
# This is to ensure that the part location is used instead
self.purchase_order2.destination = None
self.purchase_order2.save()
for line in self.purchase_order2.lines.all():
line.destination = None
line.save()
part = Part.objects.all()[0] part = Part.objects.all()[0]
part.default_location = stock_location2 part.default_location = stock_location2
part.save() part.save()
@ -332,8 +397,12 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
barcode = MOUSER_BARCODE.replace('\x1dQ3', '') barcode = MOUSER_BARCODE.replace('\x1dQ3', '')
response = self.post(url, data={'barcode': barcode}, expected_code=200) response = self.post(url, data={'barcode': barcode}, expected_code=200)
self.assertIn('action_required', response.data)
self.assertIn('lineitem', response.data) self.assertIn('lineitem', response.data)
self.assertNotIn('quantity', response.data['lineitem'])
# Quantity should be pre-filled with the remaining quantity
self.assertEqual(5, response.data['lineitem']['quantity'])
DIGIKEY_BARCODE = ( DIGIKEY_BARCODE = (

View File

@ -416,7 +416,12 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase):
for plugin in plugins: for plugin in plugins:
# Let each plugin add its own context data # Let each plugin add its own context data
try:
plugin.add_label_context(self, instance, request, context) plugin.add_label_context(self, instance, request, context)
except Exception:
InvenTree.exceptions.log_error(
f'plugins.{plugin.slug}.add_label_context'
)
return context return context

View File

@ -533,7 +533,13 @@ class StockItem(
# If a non-null value is returned (by any plugin) we will use that # If a non-null value is returned (by any plugin) we will use that
for plugin in registry.with_mixin('validation'): for plugin in registry.with_mixin('validation'):
try:
serial_int = plugin.convert_serial_to_int(serial) serial_int = plugin.convert_serial_to_int(serial)
except Exception:
InvenTree.exceptions.log_error(
f'plugin.{plugin.slug}.convert_serial_to_int'
)
serial_int = None
# Save the first returned result # Save the first returned result
if serial_int is not None: if serial_int is not None: