mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
3410534f29
commit
99c92ff655
@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""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 = """
|
||||||
|
|
||||||
|
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
|
v159 -> 2023-12-08 : https://github.com/inventree/InvenTree/pull/6056
|
||||||
- Adds API endpoint for reloading plugin registry
|
- Adds API endpoint for reloading plugin registry
|
||||||
|
|
||||||
|
@ -1645,8 +1645,17 @@ class SalesOrderAllocation(models.Model):
|
|||||||
if self.quantity > self.item.quantity:
|
if self.quantity > self.item.quantity:
|
||||||
errors['quantity'] = _('Allocation quantity cannot exceed stock 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?
|
# Ensure that we do not 'over allocate' a stock item
|
||||||
if self.item.quantity - self.item.allocation_count() + self.quantity < self.quantity:
|
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')
|
errors['quantity'] = _('Stock item is over-allocated')
|
||||||
|
|
||||||
if self.quantity <= 0:
|
if self.quantity <= 0:
|
||||||
|
@ -137,6 +137,37 @@ class SalesOrderTest(TestCase):
|
|||||||
quantity=25 if full else 20
|
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):
|
def test_allocate_partial(self):
|
||||||
"""Partially allocate stock"""
|
"""Partially allocate stock"""
|
||||||
self.allocate_stock(False)
|
self.allocate_stock(False)
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from django.db.models import F
|
||||||
from django.urls import path, re_path
|
from django.urls import path, re_path
|
||||||
from django.utils.translation import gettext_lazy as _
|
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.generics import CreateAPIView
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
import order.models
|
||||||
|
import stock.models
|
||||||
from InvenTree.helpers import hash_barcode
|
from InvenTree.helpers import hash_barcode
|
||||||
from plugin import registry
|
from plugin import registry
|
||||||
from plugin.builtin.barcodes.inventree_barcode import \
|
from plugin.builtin.barcodes.inventree_barcode import \
|
||||||
@ -272,6 +275,10 @@ class BarcodePOAllocate(BarcodeView):
|
|||||||
- A SupplierPart object
|
- A SupplierPart object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
role_required = [
|
||||||
|
'purchase_order.add'
|
||||||
|
]
|
||||||
|
|
||||||
serializer_class = barcode_serializers.BarcodePOAllocateSerializer
|
serializer_class = barcode_serializers.BarcodePOAllocateSerializer
|
||||||
|
|
||||||
def get_supplier_part(self, purchase_order, part=None, supplier_part=None, manufacturer_part=None):
|
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)
|
- location: The destination location for the received item (optional)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
role_required = [
|
||||||
|
'purchase_order.add'
|
||||||
|
]
|
||||||
|
|
||||||
serializer_class = barcode_serializers.BarcodePOReceiveSerializer
|
serializer_class = barcode_serializers.BarcodePOReceiveSerializer
|
||||||
|
|
||||||
def handle_barcode(self, barcode: str, request, **kwargs):
|
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
|
# Look for a barcode plugin which knows how to deal with this barcode
|
||||||
plugin = None
|
plugin = None
|
||||||
response = {}
|
|
||||||
|
response = {
|
||||||
|
"barcode_data": barcode,
|
||||||
|
"barcode_hash": hash_barcode(barcode)
|
||||||
|
}
|
||||||
|
|
||||||
internal_barcode_plugin = next(filter(
|
internal_barcode_plugin = next(filter(
|
||||||
lambda plugin: plugin.name == "InvenTreeBarcode", plugins
|
lambda plugin: plugin.name == "InvenTreeBarcode", plugins
|
||||||
))
|
))
|
||||||
|
|
||||||
if internal_barcode_plugin.scan(barcode):
|
if result := internal_barcode_plugin.scan(barcode):
|
||||||
response["error"] = _("Item has already been received")
|
if 'stockitem' in result:
|
||||||
raise ValidationError(response)
|
response["error"] = _("Item has already been received")
|
||||||
|
raise ValidationError(response)
|
||||||
|
|
||||||
# Now, look just for "supplier-barcode" plugins
|
# Now, look just for "supplier-barcode" plugins
|
||||||
plugins = registry.with_mixin("supplier-barcode")
|
plugins = registry.with_mixin("supplier-barcode")
|
||||||
|
|
||||||
|
plugin_response = None
|
||||||
|
|
||||||
for current_plugin in plugins:
|
for current_plugin in plugins:
|
||||||
|
|
||||||
result = current_plugin.scan_receive_item(
|
result = current_plugin.scan_receive_item(
|
||||||
@ -413,17 +431,18 @@ class BarcodePOReceive(BarcodeView):
|
|||||||
if "error" in result:
|
if "error" in result:
|
||||||
logger.info("%s.scan_receive_item(...) returned an error: %s",
|
logger.info("%s.scan_receive_item(...) returned an error: %s",
|
||||||
current_plugin.__class__.__name__, result["error"])
|
current_plugin.__class__.__name__, result["error"])
|
||||||
if not response:
|
if not plugin_response:
|
||||||
plugin = current_plugin
|
plugin = current_plugin
|
||||||
response = result
|
plugin_response = result
|
||||||
else:
|
else:
|
||||||
plugin = current_plugin
|
plugin = current_plugin
|
||||||
response = result
|
plugin_response = result
|
||||||
break
|
break
|
||||||
|
|
||||||
response["plugin"] = plugin.name if plugin else None
|
response['plugin'] = plugin.name if plugin else None
|
||||||
response["barcode_data"] = barcode
|
|
||||||
response["barcode_hash"] = hash_barcode(barcode)
|
if plugin_response:
|
||||||
|
response = {**response, **plugin_response}
|
||||||
|
|
||||||
# A plugin has not been found!
|
# A plugin has not been found!
|
||||||
if plugin is None:
|
if plugin is None:
|
||||||
@ -435,6 +454,158 @@ class BarcodePOReceive(BarcodeView):
|
|||||||
return Response(response)
|
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 = [
|
barcode_api_urls = [
|
||||||
# Link a third-party barcode to an item (e.g. Part / StockItem / etc)
|
# Link a third-party barcode to an item (e.g. Part / StockItem / etc)
|
||||||
path('link/', BarcodeAssign.as_view(), name='api-barcode-link'),
|
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
|
# Allocate parts to a purchase order by scanning their barcode
|
||||||
path("po-allocate/", BarcodePOAllocate.as_view(), name="api-barcode-po-allocate"),
|
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'
|
# 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'),
|
||||||
]
|
]
|
||||||
|
@ -7,7 +7,7 @@ from rest_framework import serializers
|
|||||||
|
|
||||||
import order.models
|
import order.models
|
||||||
import stock.models
|
import stock.models
|
||||||
from InvenTree.status_codes import PurchaseOrderStatus
|
from InvenTree.status_codes import PurchaseOrderStatus, SalesOrderStatus
|
||||||
from plugin.builtin.barcodes.inventree_barcode import \
|
from plugin.builtin.barcodes.inventree_barcode import \
|
||||||
InvenTreeInternalBarcodePlugin
|
InvenTreeInternalBarcodePlugin
|
||||||
|
|
||||||
@ -78,7 +78,7 @@ class BarcodePOAllocateSerializer(BarcodeSerializer):
|
|||||||
purchase_order = serializers.PrimaryKeyRelatedField(
|
purchase_order = serializers.PrimaryKeyRelatedField(
|
||||||
queryset=order.models.PurchaseOrder.objects.all(),
|
queryset=order.models.PurchaseOrder.objects.all(),
|
||||||
required=True,
|
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):
|
def validate_purchase_order(self, order: order.models.PurchaseOrder):
|
||||||
@ -126,3 +126,49 @@ class BarcodePOReceiveSerializer(BarcodeSerializer):
|
|||||||
raise ValidationError(_("Cannot select a structural location"))
|
raise ValidationError(_("Cannot select a structural location"))
|
||||||
|
|
||||||
return 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'),
|
||||||
|
)
|
||||||
|
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
import company.models
|
||||||
|
import order.models
|
||||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
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
|
||||||
@ -257,3 +259,175 @@ class BarcodeAPITest(InvenTreeAPITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertIn("object does not exist", str(response.data[k]))
|
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())
|
||||||
|
@ -139,33 +139,45 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
|
|||||||
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)
|
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
|
||||||
|
)
|
||||||
|
|
||||||
mouser = Company.objects.create(name="Mouser Test", is_supplier=True)
|
mouser = Company.objects.create(name="Mouser Test", is_supplier=True)
|
||||||
mpart = ManufacturerPart.objects.create(
|
mpart = ManufacturerPart.objects.create(
|
||||||
part=part, manufacturer=manufacturer, MPN="MC34063ADR")
|
part=part, manufacturer=manufacturer, MPN="MC34063ADR"
|
||||||
|
)
|
||||||
|
|
||||||
self.purchase_order1 = PurchaseOrder.objects.create(
|
self.purchase_order1 = PurchaseOrder.objects.create(
|
||||||
supplier_reference="72991337", supplier=supplier)
|
supplier_reference="72991337", supplier=supplier
|
||||||
|
)
|
||||||
|
|
||||||
supplier_parts1 = [
|
supplier_parts1 = [
|
||||||
SupplierPart(SKU=f"1_{i}", part=part, supplier=supplier)
|
SupplierPart(SKU=f"1_{i}", part=part, supplier=supplier)
|
||||||
for i in range(6)
|
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=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)
|
||||||
|
|
||||||
self.purchase_order2 = PurchaseOrder.objects.create(
|
self.purchase_order2 = PurchaseOrder.objects.create(
|
||||||
reference="P0-1337", supplier=mouser)
|
reference="P0-1337", supplier=mouser
|
||||||
|
)
|
||||||
|
|
||||||
self.purchase_order2.place_order()
|
self.purchase_order2.place_order()
|
||||||
supplier_parts2 = [
|
supplier_parts2 = [
|
||||||
SupplierPart(SKU=f"2_{i}", part=part, supplier=mouser)
|
SupplierPart(SKU=f"2_{i}", part=part, supplier=mouser)
|
||||||
for i in range(6)
|
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:
|
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)
|
||||||
@ -175,28 +187,23 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
url = reverse("api-barcode-po-receive")
|
url = reverse("api-barcode-po-receive")
|
||||||
|
|
||||||
result1 = self.post(url, data={"barcode": DIGIKEY_BARCODE})
|
result1 = self.post(url, data={"barcode": DIGIKEY_BARCODE}, expected_code=400)
|
||||||
assert result1.status_code == 400
|
|
||||||
assert result1.data["error"].startswith("No matching purchase order")
|
assert result1.data["error"].startswith("No matching purchase order")
|
||||||
|
|
||||||
self.purchase_order1.place_order()
|
self.purchase_order1.place_order()
|
||||||
|
|
||||||
result2 = self.post(url, data={"barcode": DIGIKEY_BARCODE})
|
result2 = self.post(url, data={"barcode": DIGIKEY_BARCODE}, expected_code=200)
|
||||||
assert result2.status_code == 200
|
self.assertIn("success", result2.data)
|
||||||
assert "success" in result2.data
|
|
||||||
|
|
||||||
result3 = self.post(url, data={"barcode": DIGIKEY_BARCODE})
|
result3 = self.post(url, data={"barcode": DIGIKEY_BARCODE}, expected_code=400)
|
||||||
assert result3.status_code == 400
|
self.assertEqual(result3.data['error'], "Item has already been received")
|
||||||
assert result3.data["error"].startswith(
|
|
||||||
"Item has already been received")
|
|
||||||
|
|
||||||
result4 = self.post(url, data={"barcode": DIGIKEY_BARCODE[:-1]})
|
result4 = self.post(url, data={"barcode": DIGIKEY_BARCODE[:-1]}, expected_code=400)
|
||||||
assert result4.status_code == 400
|
|
||||||
assert result4.data["error"].startswith(
|
assert result4.data["error"].startswith(
|
||||||
"Failed to find pending line item for supplier part")
|
"Failed to find pending line item for supplier part")
|
||||||
|
|
||||||
result5 = self.post(reverse("api-barcode-scan"), data={"barcode": DIGIKEY_BARCODE})
|
result5 = self.post(reverse("api-barcode-scan"), data={"barcode": DIGIKEY_BARCODE}, expected_code=200)
|
||||||
assert result5.status_code == 200
|
|
||||||
stock_item = StockItem.objects.get(pk=result5.data["stockitem"]["pk"])
|
stock_item = StockItem.objects.get(pk=result5.data["stockitem"]["pk"])
|
||||||
assert stock_item.supplier_part.SKU == "296-LM358BIDDFRCT-ND"
|
assert stock_item.supplier_part.SKU == "296-LM358BIDDFRCT-ND"
|
||||||
assert stock_item.quantity == 10
|
assert stock_item.quantity == 10
|
||||||
|
@ -1106,7 +1106,7 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo
|
|||||||
|
|
||||||
return total
|
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.
|
"""Return a queryset for SalesOrderAllocations against this StockItem, with optional filters.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
@ -1114,6 +1114,12 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo
|
|||||||
"""
|
"""
|
||||||
query = self.sales_order_allocations.all()
|
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:
|
if active is True:
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
line__order__status__in=SalesOrderStatusGroups.OPEN,
|
line__order__status__in=SalesOrderStatusGroups.OPEN,
|
||||||
@ -1128,9 +1134,9 @@ class StockItem(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, commo
|
|||||||
|
|
||||||
return query
|
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."""
|
"""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)))
|
query = query.aggregate(q=Coalesce(Sum('quantity'), Decimal(0)))
|
||||||
|
|
||||||
total = query['q']
|
total = query['q']
|
||||||
|
@ -419,6 +419,18 @@ function barcodeScanDialog(options={}) {
|
|||||||
let modal = options.modal || createNewModal();
|
let modal = options.modal || createNewModal();
|
||||||
let title = options.title || '{% trans "Scan Barcode" %}';
|
let title = options.title || '{% trans "Scan Barcode" %}';
|
||||||
|
|
||||||
|
const matching_models = [
|
||||||
|
'build',
|
||||||
|
'manufacturerpart',
|
||||||
|
'part',
|
||||||
|
'purchaseorder',
|
||||||
|
'returnorder',
|
||||||
|
'salesorder',
|
||||||
|
'supplierpart',
|
||||||
|
'stockitem',
|
||||||
|
'stocklocation'
|
||||||
|
];
|
||||||
|
|
||||||
barcodeDialog(
|
barcodeDialog(
|
||||||
title,
|
title,
|
||||||
{
|
{
|
||||||
@ -428,19 +440,24 @@ function barcodeScanDialog(options={}) {
|
|||||||
if (options.onScan) {
|
if (options.onScan) {
|
||||||
options.onScan(response);
|
options.onScan(response);
|
||||||
} else {
|
} 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;
|
// No match
|
||||||
|
showBarcodeMessage(
|
||||||
if (url) {
|
modal,
|
||||||
$(modal).modal('hide');
|
'{% trans "No URL in response" %}',
|
||||||
window.location.href = url;
|
'warning'
|
||||||
} else {
|
);
|
||||||
showBarcodeMessage(
|
|
||||||
modal,
|
|
||||||
'{% trans "No URL in response" %}',
|
|
||||||
'warning'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -112,3 +112,15 @@ From the [Purchase Order detail page](./po.md#purchase-order-detail) page, the f
|
|||||||
#### Scan Received Parts
|
#### Scan Received Parts
|
||||||
|
|
||||||
Receive incoming purchase order items against the selected purchase order. Scanning a *new* barcode which is associated with an item in an incoming purchase order will receive the item into stock.
|
Receive incoming purchase order items against the selected purchase order. Scanning a *new* barcode which is associated with an item in an incoming purchase order will receive the item into stock.
|
||||||
|
|
||||||
|
### Sales Order Actions
|
||||||
|
|
||||||
|
The following barcode actions are available for [Sales Orders](./so.md):
|
||||||
|
|
||||||
|
#### Add Line Item
|
||||||
|
|
||||||
|
Add a new line item to the selected order by scanning a *Part* barcode
|
||||||
|
|
||||||
|
#### Assign Stock
|
||||||
|
|
||||||
|
Allocate stock items to the selected sales order by scanning a *StockItem* barcode
|
||||||
|
@ -4,7 +4,7 @@ title: Purchase Orders
|
|||||||
|
|
||||||
## Purchase Order List
|
## Purchase Order List
|
||||||
|
|
||||||
The purchase order list display shows all current *outstanding* purchase orders. (Purchase orders which have been completed are not shown here).
|
The purchase order list display lists all purchase orders:
|
||||||
|
|
||||||
{% with id="po_list", url="app/po_list.png", maxheight="240px", description="Purchase order list" %}
|
{% with id="po_list", url="app/po_list.png", maxheight="240px", description="Purchase order list" %}
|
||||||
{% include "img.html" %}
|
{% include "img.html" %}
|
||||||
@ -14,7 +14,7 @@ Select an individual purchase order to display the detail view for that order.
|
|||||||
|
|
||||||
### Filtering
|
### Filtering
|
||||||
|
|
||||||
Available purchase orders can be subsequently filtered using the search input at the top of the screen
|
Displayed purchase orders can be subsequently filtered using the search input at the top of the screen
|
||||||
|
|
||||||
## Purchase Order Detail
|
## Purchase Order Detail
|
||||||
|
|
||||||
|
37
docs/docs/app/so.md
Normal file
37
docs/docs/app/so.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
title: Sales Orders
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sales Order List
|
||||||
|
|
||||||
|
The sales order list display shows all sales orders:
|
||||||
|
|
||||||
|
{% with id="so_list", url="app/so_list.png", maxheight="240px", description="Sales order list" %}
|
||||||
|
{% include "img.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
Select an individual sales order to display the detail view for that order.
|
||||||
|
|
||||||
|
### Filtering
|
||||||
|
|
||||||
|
Displayed sales orders can be subsequently filtered using the search input at the top of the screen
|
||||||
|
|
||||||
|
## Sales Order Detail
|
||||||
|
|
||||||
|
Select an individual order to show the detailed view for that order:
|
||||||
|
|
||||||
|
{% with id="so_detail", url="app/so_detail.png", maxheight="240px", description="Sales order details" %}
|
||||||
|
{% include "img.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
### Edit Order Details
|
||||||
|
|
||||||
|
From the detail view, select the *Edit* button in the top-right of the screen. This opens the sales order editing display.
|
||||||
|
|
||||||
|
### Line Items
|
||||||
|
|
||||||
|
View the line items associated with the selected order:
|
||||||
|
|
||||||
|
{% with id="so_lines", url="app/so_lines.png", maxheight="240px", description="Sales order lines" %}
|
||||||
|
{% include "img.html" %}
|
||||||
|
{% endwith %}
|
BIN
docs/docs/assets/images/app/so_detail.png
Normal file
BIN
docs/docs/assets/images/app/so_detail.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 176 KiB |
BIN
docs/docs/assets/images/app/so_lines.png
Normal file
BIN
docs/docs/assets/images/app/so_lines.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 268 KiB |
BIN
docs/docs/assets/images/app/so_list.png
Normal file
BIN
docs/docs/assets/images/app/so_list.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 215 KiB |
@ -174,6 +174,7 @@ nav:
|
|||||||
- Parts: app/part.md
|
- Parts: app/part.md
|
||||||
- Stock: app/stock.md
|
- Stock: app/stock.md
|
||||||
- Purchase Orders: app/po.md
|
- Purchase Orders: app/po.md
|
||||||
|
- Sales Orders: app/so.md
|
||||||
- Settings: app/settings.md
|
- Settings: app/settings.md
|
||||||
- Privacy: app/privacy.md
|
- Privacy: app/privacy.md
|
||||||
- Translation: app/translation.md
|
- Translation: app/translation.md
|
||||||
|
Loading…
Reference in New Issue
Block a user