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 = 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
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
|
@ -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'),
|
||||
]
|
||||
|
@ -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'),
|
||||
)
|
||||
|
@ -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())
|
||||
|
@ -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
|
||||
|
@ -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']
|
||||
|
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -112,3 +112,15 @@ From the [Purchase Order detail page](./po.md#purchase-order-detail) page, the f
|
||||
#### 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.
|
||||
|
||||
### 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
|
||||
|
||||
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" %}
|
||||
{% include "img.html" %}
|
||||
@ -14,7 +14,7 @@ Select an individual purchase order to display the detail view for that order.
|
||||
|
||||
### 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
|
||||
|
||||
|
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
|
||||
- Stock: app/stock.md
|
||||
- Purchase Orders: app/po.md
|
||||
- Sales Orders: app/so.md
|
||||
- Settings: app/settings.md
|
||||
- Privacy: app/privacy.md
|
||||
- Translation: app/translation.md
|
||||
|
Loading…
Reference in New Issue
Block a user