2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 20:45:44 +00:00

Sales order barcode allocate (#6072)

* Bug fix for BarcodePOReceive endpoint

- Existing scan must match "stockitem" to raise an error

* bug fix: barcode.js

- Handle new return data from barcode scan endpoint

* Add barcode endpoint for allocating stock to sales order

* Improve logic for preventing over allocation of stock item to sales order

* Test for sufficient quantity

* Bump API version

* Bug fix and extra check

* Cleanup unit tests

* Add unit testing for new endpoint

* Add blank page for app sales orders docs

* Add docs for new barcode features in app

* Fix unit tests

* Remove debug statement
This commit is contained in:
Oliver
2023-12-14 11:13:50 +11:00
committed by GitHub
parent 3410534f29
commit 99c92ff655
16 changed files with 569 additions and 52 deletions

View File

@ -2,11 +2,14 @@
# InvenTree API version
INVENTREE_API_VERSION = 159
INVENTREE_API_VERSION = 160
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v160 -> 2023-012-11 : https://github.com/inventree/InvenTree/pull/6072
- Adds API endpoint for allocating stock items against a sales order via barcode scan
v159 -> 2023-12-08 : https://github.com/inventree/InvenTree/pull/6056
- Adds API endpoint for reloading plugin registry

View File

@ -1645,8 +1645,17 @@ class SalesOrderAllocation(models.Model):
if self.quantity > self.item.quantity:
errors['quantity'] = _('Allocation quantity cannot exceed stock quantity')
# TODO: The logic here needs improving. Do we need to subtract our own amount, or something?
if self.item.quantity - self.item.allocation_count() + self.quantity < self.quantity:
# Ensure that we do not 'over allocate' a stock item
build_allocation_count = self.item.build_allocation_count()
sales_allocation_count = self.item.sales_order_allocation_count(
exclude_allocations={
"pk": self.pk,
}
)
total_allocation = build_allocation_count + sales_allocation_count + self.quantity
if total_allocation > self.item.quantity:
errors['quantity'] = _('Stock item is over-allocated')
if self.quantity <= 0:

View File

@ -137,6 +137,37 @@ class SalesOrderTest(TestCase):
quantity=25 if full else 20
)
def test_over_allocate(self):
"""Test that over allocation logic works"""
SA = StockItem.objects.create(part=self.part, quantity=9)
# First three allocations should succeed
for _i in range(3):
allocation = SalesOrderAllocation.objects.create(
line=self.line,
item=SA,
quantity=3,
shipment=self.shipment
)
# Editing an existing allocation with a larger quantity should fail
with self.assertRaises(ValidationError):
allocation.quantity = 4
allocation.save()
allocation.clean()
# Next allocation should fail
with self.assertRaises(ValidationError):
allocation = SalesOrderAllocation.objects.create(
line=self.line,
item=SA,
quantity=3,
shipment=self.shipment
)
allocation.clean()
def test_allocate_partial(self):
"""Partially allocate stock"""
self.allocate_stock(False)

View File

@ -2,6 +2,7 @@
import logging
from django.db.models import F
from django.urls import path, re_path
from django.utils.translation import gettext_lazy as _
@ -10,6 +11,8 @@ from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.generics import CreateAPIView
from rest_framework.response import Response
import order.models
import stock.models
from InvenTree.helpers import hash_barcode
from plugin import registry
from plugin.builtin.barcodes.inventree_barcode import \
@ -272,6 +275,10 @@ class BarcodePOAllocate(BarcodeView):
- A SupplierPart object
"""
role_required = [
'purchase_order.add'
]
serializer_class = barcode_serializers.BarcodePOAllocateSerializer
def get_supplier_part(self, purchase_order, part=None, supplier_part=None, manufacturer_part=None):
@ -370,6 +377,10 @@ class BarcodePOReceive(BarcodeView):
- location: The destination location for the received item (optional)
"""
role_required = [
'purchase_order.add'
]
serializer_class = barcode_serializers.BarcodePOReceiveSerializer
def handle_barcode(self, barcode: str, request, **kwargs):
@ -385,19 +396,26 @@ class BarcodePOReceive(BarcodeView):
# Look for a barcode plugin which knows how to deal with this barcode
plugin = None
response = {}
response = {
"barcode_data": barcode,
"barcode_hash": hash_barcode(barcode)
}
internal_barcode_plugin = next(filter(
lambda plugin: plugin.name == "InvenTreeBarcode", plugins
))
if internal_barcode_plugin.scan(barcode):
response["error"] = _("Item has already been received")
raise ValidationError(response)
if result := internal_barcode_plugin.scan(barcode):
if 'stockitem' in result:
response["error"] = _("Item has already been received")
raise ValidationError(response)
# Now, look just for "supplier-barcode" plugins
plugins = registry.with_mixin("supplier-barcode")
plugin_response = None
for current_plugin in plugins:
result = current_plugin.scan_receive_item(
@ -413,17 +431,18 @@ class BarcodePOReceive(BarcodeView):
if "error" in result:
logger.info("%s.scan_receive_item(...) returned an error: %s",
current_plugin.__class__.__name__, result["error"])
if not response:
if not plugin_response:
plugin = current_plugin
response = result
plugin_response = result
else:
plugin = current_plugin
response = result
plugin_response = result
break
response["plugin"] = plugin.name if plugin else None
response["barcode_data"] = barcode
response["barcode_hash"] = hash_barcode(barcode)
response['plugin'] = plugin.name if plugin else None
if plugin_response:
response = {**response, **plugin_response}
# A plugin has not been found!
if plugin is None:
@ -435,6 +454,158 @@ class BarcodePOReceive(BarcodeView):
return Response(response)
class BarcodeSOAllocate(BarcodeView):
"""Endpoint for allocating stock to a sales order, by scanning barcode.
The scanned barcode should map to a StockItem object.
Additional fields can be passed to the endpoint:
- SalesOrder (Required)
- Line Item
- Shipment
- Quantity
"""
role_required = [
'sales_order.add',
]
serializer_class = barcode_serializers.BarcodeSOAllocateSerializer
def get_line_item(self, stock_item, **kwargs):
"""Return the matching line item for the provided stock item"""
# Extract sales order object (required field)
sales_order = kwargs['sales_order']
# Next, check if a line-item is provided (optional field)
if line_item := kwargs.get('line', None):
return line_item
# If not provided, we need to find the correct line item
parts = stock_item.part.get_ancestors(include_self=True)
# Find any matching line items for the stock item
lines = order.models.SalesOrderLineItem.objects.filter(
order=sales_order,
part__in=parts,
shipped__lte=F('quantity'),
)
if lines.count() > 1:
raise ValidationError({
'error': _('Multiple matching line items found'),
})
if lines.count() == 0:
raise ValidationError({
'error': _('No matching line item found'),
})
return lines.first()
def get_shipment(self, **kwargs):
"""Extract the shipment from the provided kwargs, or guess"""
sales_order = kwargs['sales_order']
if shipment := kwargs.get('shipment', None):
if shipment.order != sales_order:
raise ValidationError({
'error': _('Shipment does not match sales order'),
})
return shipment
shipments = order.models.SalesOrderShipment.objects.filter(
order=sales_order,
delivery_date=None
)
if shipments.count() == 1:
return shipments.first()
# If shipment cannot be determined, return None
return None
def handle_barcode(self, barcode: str, request, **kwargs):
"""Handle barcode scan for sales order allocation."""
logger.debug("BarcodeSOAllocate: scanned barcode - '%s'", barcode)
result = self.scan_barcode(barcode, request, **kwargs)
if result['plugin'] is None:
result['error'] = _('No match found for barcode data')
raise ValidationError(result)
# Check that the scanned barcode was a StockItem
if 'stockitem' not in result:
result['error'] = _('Barcode does not match an existing stock item')
raise ValidationError(result)
try:
stock_item_id = result['stockitem'].get('pk', None)
stock_item = stock.models.StockItem.objects.get(pk=stock_item_id)
except (ValueError, stock.models.StockItem.DoesNotExist):
result['error'] = _('Barcode does not match an existing stock item')
raise ValidationError(result)
# At this stage, we have a valid StockItem object
# Extract any other data from the kwargs
line_item = self.get_line_item(stock_item, **kwargs)
sales_order = kwargs['sales_order']
shipment = self.get_shipment(**kwargs)
if stock_item is not None and line_item is not None:
if stock_item.part != line_item.part:
result['error'] = _('Stock item does not match line item')
raise ValidationError(result)
quantity = kwargs.get('quantity', None)
# Override quantity for serialized items
if stock_item.serialized:
quantity = 1
if quantity is None:
quantity = line_item.quantity - line_item.shipped
quantity = min(quantity, stock_item.unallocated_quantity())
response = {
'stock_item': stock_item.pk if stock_item else None,
'part': stock_item.part.pk if stock_item else None,
'sales_order': sales_order.pk if sales_order else None,
'line_item': line_item.pk if line_item else None,
'shipment': shipment.pk if shipment else None,
'quantity': quantity
}
if stock_item is not None and quantity is not None:
if stock_item.unallocated_quantity() < quantity:
response['error'] = _('Insufficient stock available')
raise ValidationError(response)
# If we have sufficient information, we can allocate the stock item
if all((x is not None for x in [line_item, sales_order, shipment, quantity])):
order.models.SalesOrderAllocation.objects.create(
line=line_item,
shipment=shipment,
item=stock_item,
quantity=quantity,
)
response['success'] = _('Stock item allocated to sales order')
return Response(response)
response['error'] = _('Not enough information')
response['action_required'] = True
raise ValidationError(response)
barcode_api_urls = [
# Link a third-party barcode to an item (e.g. Part / StockItem / etc)
path('link/', BarcodeAssign.as_view(), name='api-barcode-link'),
@ -448,6 +619,9 @@ barcode_api_urls = [
# Allocate parts to a purchase order by scanning their barcode
path("po-allocate/", BarcodePOAllocate.as_view(), name="api-barcode-po-allocate"),
# Allocate stock to a sales order by scanning barcode
path("so-allocate/", BarcodeSOAllocate.as_view(), name="api-barcode-so-allocate"),
# Catch-all performs barcode 'scan'
re_path(r'^.*$', BarcodeScan.as_view(), name='api-barcode-scan'),
]

View File

@ -7,7 +7,7 @@ from rest_framework import serializers
import order.models
import stock.models
from InvenTree.status_codes import PurchaseOrderStatus
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
from plugin.builtin.barcodes.inventree_barcode import \
InvenTreeInternalBarcodePlugin
@ -78,7 +78,7 @@ class BarcodePOAllocateSerializer(BarcodeSerializer):
purchase_order = serializers.PrimaryKeyRelatedField(
queryset=order.models.PurchaseOrder.objects.all(),
required=True,
help_text=_('PurchaseOrder to allocate items against'),
help_text=_('Purchase Order to allocate items against'),
)
def validate_purchase_order(self, order: order.models.PurchaseOrder):
@ -126,3 +126,49 @@ class BarcodePOReceiveSerializer(BarcodeSerializer):
raise ValidationError(_("Cannot select a structural location"))
return location
class BarcodeSOAllocateSerializer(BarcodeSerializer):
"""Serializr for allocating stock items to a sales order
The scanned barcode must map to a StockItem object
"""
sales_order = serializers.PrimaryKeyRelatedField(
queryset=order.models.SalesOrder.objects.all(),
required=True,
help_text=_('Sales Order to allocate items against'),
)
def validate_sales_order(self, order: order.models.SalesOrder):
"""Validate the provided order"""
if order and order.status != SalesOrderStatus.PENDING.value:
raise ValidationError(_("Sales order is not pending"))
return order
line = serializers.PrimaryKeyRelatedField(
queryset=order.models.SalesOrderLineItem.objects.all(),
required=False, allow_null=True,
help_text=_('Sales order line item to allocate items against'),
)
shipment = serializers.PrimaryKeyRelatedField(
queryset=order.models.SalesOrderShipment.objects.all(),
required=False, allow_null=True,
help_text=_('Sales order shipment to allocate items against'),
)
def validate_shipment(self, shipment: order.models.SalesOrderShipment):
"""Validate the provided shipment"""
if shipment and shipment.is_delivered():
raise ValidationError(_("Shipment has already been delivered"))
return shipment
quantity = serializers.IntegerField(
required=False,
help_text=_('Quantity to allocate'),
)

View File

@ -2,6 +2,8 @@
from django.urls import reverse
import company.models
import order.models
from InvenTree.unit_test import InvenTreeAPITestCase
from part.models import Part
from stock.models import StockItem
@ -257,3 +259,175 @@ class BarcodeAPITest(InvenTreeAPITestCase):
)
self.assertIn("object does not exist", str(response.data[k]))
class SOAllocateTest(InvenTreeAPITestCase):
"""Unit tests for the barcode endpoint for allocating items to a sales order"""
fixtures = [
'category',
'company',
'part',
'location',
'stock',
]
@classmethod
def setUpTestData(cls):
"""Setup for all tests."""
super().setUpTestData()
# Assign required roles
cls.assignRole('sales_order.change')
cls.assignRole('sales_order.add')
# Find a salable part
cls.part = Part.objects.filter(salable=True).first()
# Make a stock item
cls.stock_item = StockItem.objects.create(
part=cls.part,
quantity=100
)
cls.stock_item.assign_barcode(barcode_data='barcode')
# Find a customer
cls.customer = company.models.Company.objects.filter(
is_customer=True
).first()
# Create a sales order
cls.sales_order = order.models.SalesOrder.objects.create(
customer=cls.customer
)
# Create a shipment
cls.shipment = order.models.SalesOrderShipment.objects.create(
order=cls.sales_order
)
# Create a line item
cls.line_item = order.models.SalesOrderLineItem.objects.create(
order=cls.sales_order,
part=cls.part,
quantity=10,
)
def setUp(self):
"""Setup method for each test"""
super().setUp()
def postBarcode(self, barcode, expected_code=None, **kwargs):
"""Post barcode and return results."""
data = {
'barcode': barcode,
**kwargs
}
response = self.post(
reverse('api-barcode-so-allocate'),
data=data,
expected_code=expected_code,
)
return response.data
def test_no_data(self):
"""Test when no data is provided"""
result = self.postBarcode('', expected_code=400)
self.assertIn('This field may not be blank', str(result['barcode']))
self.assertIn('This field is required', str(result['sales_order']))
def test_invalid_sales_order(self):
"""Test when an invalid sales order is provided"""
# Test with an invalid sales order ID
result = self.postBarcode(
'',
sales_order=999999999,
expected_code=400
)
self.assertIn('object does not exist', str(result['sales_order']))
def test_invalid_barcode(self):
"""Test when an invalid barcode is provided (does not match stock item)"""
# Test with an invalid barcode
result = self.postBarcode(
'123456789',
sales_order=self.sales_order.pk,
expected_code=400
)
self.assertIn('No match found for barcode', str(result['error']))
# Test with a barcode that matches a *different* stock item
item = StockItem.objects.exclude(pk=self.stock_item.pk).first()
item.assign_barcode(barcode_data='123456789')
result = self.postBarcode(
'123456789',
sales_order=self.sales_order.pk,
expected_code=400
)
self.assertIn('No matching line item found', str(result['error']))
# Test with barcode which points to a *part* instance
item.part.assign_barcode(barcode_data='abcde')
result = self.postBarcode(
'abcde',
sales_order=self.sales_order.pk,
expected_code=400
)
self.assertIn('does not match an existing stock item', str(result['error']))
def test_submit(self):
"""Test data submission"""
# Create a shipment for a different order
other_order = order.models.SalesOrder.objects.create(
customer=self.customer
)
other_shipment = order.models.SalesOrderShipment.objects.create(
order=other_order
)
# Test with invalid shipment
response = self.postBarcode(
self.stock_item.format_barcode(),
sales_order=self.sales_order.pk,
shipment=other_shipment.pk,
expected_code=400
)
self.assertIn('Shipment does not match sales order', str(response['error']))
# No stock has been allocated
self.assertEqual(self.line_item.allocated_quantity(), 0)
# Test with minimum valid data - this should be enough information to allocate stock
response = self.postBarcode(
self.stock_item.format_barcode(),
sales_order=self.sales_order.pk,
expected_code=200
)
# Check that the right data has been extracted
self.assertIn('Stock item allocated', str(response['success']))
self.assertEqual(response['sales_order'], self.sales_order.pk)
self.assertEqual(response['line_item'], self.line_item.pk)
self.assertEqual(response['shipment'], self.shipment.pk)
self.assertEqual(response['quantity'], 10)
self.line_item.refresh_from_db()
self.assertEqual(self.line_item.allocated_quantity(), 10)
self.assertTrue(self.line_item.is_fully_allocated())

View File

@ -139,33 +139,45 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
part = Part.objects.create(name="Test Part", description="Test Part")
supplier = Company.objects.create(name="Supplier", is_supplier=True)
manufacturer = Company.objects.create(
name="Test Manufacturer", is_manufacturer=True)
name="Test Manufacturer", is_manufacturer=True
)
mouser = Company.objects.create(name="Mouser Test", is_supplier=True)
mpart = ManufacturerPart.objects.create(
part=part, manufacturer=manufacturer, MPN="MC34063ADR")
part=part, manufacturer=manufacturer, MPN="MC34063ADR"
)
self.purchase_order1 = PurchaseOrder.objects.create(
supplier_reference="72991337", supplier=supplier)
supplier_reference="72991337", supplier=supplier
)
supplier_parts1 = [
SupplierPart(SKU=f"1_{i}", part=part, supplier=supplier)
for i in range(6)
]
supplier_parts1.insert(
2, SupplierPart(SKU="296-LM358BIDDFRCT-ND", part=part, supplier=supplier))
2, SupplierPart(SKU="296-LM358BIDDFRCT-ND", part=part, supplier=supplier)
)
for supplier_part in supplier_parts1:
supplier_part.save()
self.purchase_order1.add_line_item(supplier_part, 8)
self.purchase_order2 = PurchaseOrder.objects.create(
reference="P0-1337", supplier=mouser)
reference="P0-1337", supplier=mouser
)
self.purchase_order2.place_order()
supplier_parts2 = [
SupplierPart(SKU=f"2_{i}", part=part, supplier=mouser)
for i in range(6)
]
supplier_parts2.insert(
3, SupplierPart(SKU="42", part=part, manufacturer_part=mpart, supplier=mouser))
supplier_parts2.insert(3, SupplierPart(
SKU="42", part=part, manufacturer_part=mpart, supplier=mouser
))
for supplier_part in supplier_parts2:
supplier_part.save()
self.purchase_order2.add_line_item(supplier_part, 5)
@ -175,28 +187,23 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
url = reverse("api-barcode-po-receive")
result1 = self.post(url, data={"barcode": DIGIKEY_BARCODE})
assert result1.status_code == 400
result1 = self.post(url, data={"barcode": DIGIKEY_BARCODE}, expected_code=400)
assert result1.data["error"].startswith("No matching purchase order")
self.purchase_order1.place_order()
result2 = self.post(url, data={"barcode": DIGIKEY_BARCODE})
assert result2.status_code == 200
assert "success" in result2.data
result2 = self.post(url, data={"barcode": DIGIKEY_BARCODE}, expected_code=200)
self.assertIn("success", result2.data)
result3 = self.post(url, data={"barcode": DIGIKEY_BARCODE})
assert result3.status_code == 400
assert result3.data["error"].startswith(
"Item has already been received")
result3 = self.post(url, data={"barcode": DIGIKEY_BARCODE}, expected_code=400)
self.assertEqual(result3.data['error'], "Item has already been received")
result4 = self.post(url, data={"barcode": DIGIKEY_BARCODE[:-1]})
assert result4.status_code == 400
result4 = self.post(url, data={"barcode": DIGIKEY_BARCODE[:-1]}, expected_code=400)
assert result4.data["error"].startswith(
"Failed to find pending line item for supplier part")
result5 = self.post(reverse("api-barcode-scan"), data={"barcode": DIGIKEY_BARCODE})
assert result5.status_code == 200
result5 = self.post(reverse("api-barcode-scan"), data={"barcode": DIGIKEY_BARCODE}, expected_code=200)
stock_item = StockItem.objects.get(pk=result5.data["stockitem"]["pk"])
assert stock_item.supplier_part.SKU == "296-LM358BIDDFRCT-ND"
assert stock_item.quantity == 10

View File

@ -1106,7 +1106,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo
return total
def get_sales_order_allocations(self, active=True):
def get_sales_order_allocations(self, active=True, **kwargs):
"""Return a queryset for SalesOrderAllocations against this StockItem, with optional filters.
Arguments:
@ -1114,6 +1114,12 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo
"""
query = self.sales_order_allocations.all()
if filter_allocations := kwargs.get('filter_allocations', None):
query = query.filter(**filter_allocations)
if exclude_allocations := kwargs.get('exclude_allocations', None):
query = query.exclude(**exclude_allocations)
if active is True:
query = query.filter(
line__order__status__in=SalesOrderStatusGroups.OPEN,
@ -1128,9 +1134,9 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo
return query
def sales_order_allocation_count(self, active=True):
def sales_order_allocation_count(self, active=True, **kwargs):
"""Return the total quantity allocated to SalesOrders."""
query = self.get_sales_order_allocations(active=active)
query = self.get_sales_order_allocations(active=active, **kwargs)
query = query.aggregate(q=Coalesce(Sum('quantity'), Decimal(0)))
total = query['q']

View File

@ -419,6 +419,18 @@ function barcodeScanDialog(options={}) {
let modal = options.modal || createNewModal();
let title = options.title || '{% trans "Scan Barcode" %}';
const matching_models = [
'build',
'manufacturerpart',
'part',
'purchaseorder',
'returnorder',
'salesorder',
'supplierpart',
'stockitem',
'stocklocation'
];
barcodeDialog(
title,
{
@ -428,19 +440,24 @@ function barcodeScanDialog(options={}) {
if (options.onScan) {
options.onScan(response);
} else {
// Find matching model
matching_models.forEach(function(model) {
if (model in response) {
let instance = response[model];
let url = instance.web_url || instance.url;
if (url) {
window.location.href = url;
return;
}
}
});
let url = response.url;
if (url) {
$(modal).modal('hide');
window.location.href = url;
} else {
showBarcodeMessage(
modal,
'{% trans "No URL in response" %}',
'warning'
);
}
// No match
showBarcodeMessage(
modal,
'{% trans "No URL in response" %}',
'warning'
);
}
}
},