diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py
index 0a9d39225b..98957d7bba 100644
--- a/InvenTree/InvenTree/version.py
+++ b/InvenTree/InvenTree/version.py
@@ -12,11 +12,14 @@ import common.models
INVENTREE_SW_VERSION = "0.7.0 dev"
# InvenTree API version
-INVENTREE_API_VERSION = 36
+INVENTREE_API_VERSION = 37
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
+v37 -> 2022-04-07 : https://github.com/inventree/InvenTree/pull/2806
+ - Adds extra stock availability information to the BomItem serializer
+
v36 -> 2022-04-03
- Adds ability to filter part list endpoint by unallocated_stock argument
diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index e1b0dda61f..de6cd4a974 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -1602,9 +1602,10 @@ class BomList(generics.ListCreateAPIView):
def get_queryset(self, *args, **kwargs):
- queryset = BomItem.objects.all()
+ queryset = super().get_queryset(*args, **kwargs)
queryset = self.get_serializer_class().setup_eager_loading(queryset)
+ queryset = self.get_serializer_class().annotate_queryset(queryset)
return queryset
@@ -1818,6 +1819,15 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = BomItem.objects.all()
serializer_class = part_serializers.BomItemSerializer
+ def get_queryset(self, *args, **kwargs):
+
+ queryset = super().get_queryset(*args, **kwargs)
+
+ queryset = self.get_serializer_class().setup_eager_loading(queryset)
+ queryset = self.get_serializer_class().annotate_queryset(queryset)
+
+ return queryset
+
class BomItemValidate(generics.UpdateAPIView):
""" API endpoint for validating a BomItem """
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index c493028d71..d67ff31c8c 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -2882,23 +2882,6 @@ class BomItem(models.Model, DataImportMixin):
child=self.sub_part.full_name,
n=decimal2string(self.quantity))
- def available_stock(self):
- """
- Return the available stock items for the referenced sub_part
- """
-
- query = self.sub_part.stock_items.all()
-
- query = query.prefetch_related([
- 'sub_part__stock_items',
- ])
-
- query = query.filter(StockModels.StockItem.IN_STOCK_FILTER).aggregate(
- available=Coalesce(Sum('quantity'), 0)
- )
-
- return query['available']
-
def get_overage_quantity(self, quantity):
""" Calculate overage quantity
"""
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index c352c59eab..8bf3d77501 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -577,6 +577,10 @@ class BomItemSerializer(InvenTreeModelSerializer):
purchase_price_range = serializers.SerializerMethodField()
+ # Annotated fields
+ available_stock = serializers.FloatField(read_only=True)
+ available_substitute_stock = serializers.FloatField(read_only=True)
+
def __init__(self, *args, **kwargs):
# part_detail and sub_part_detail serializers are only included if requested.
# This saves a bunch of database requests
@@ -609,10 +613,110 @@ class BomItemSerializer(InvenTreeModelSerializer):
queryset = queryset.prefetch_related('sub_part')
queryset = queryset.prefetch_related('sub_part__category')
- queryset = queryset.prefetch_related('sub_part__stock_items')
+ queryset = queryset.prefetch_related(
+ 'sub_part__stock_items',
+ 'sub_part__stock_items__allocations',
+ 'sub_part__stock_items__sales_order_allocations',
+ )
queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks')
return queryset
+ @staticmethod
+ def annotate_queryset(queryset):
+ """
+ Annotate the BomItem queryset with extra information:
+
+ Annotations:
+ available_stock: The amount of stock available for the sub_part Part object
+ """
+
+ """
+ Construct an "available stock" quantity:
+ available_stock = total_stock - build_order_allocations - sales_order_allocations
+ """
+
+ build_order_filter = Q(build__status__in=BuildStatus.ACTIVE_CODES)
+ sales_order_filter = Q(
+ line__order__status__in=SalesOrderStatus.OPEN,
+ shipment__shipment_date=None,
+ )
+
+ # Calculate "total stock" for the referenced sub_part
+ # Calculate the "build_order_allocations" for the sub_part
+ # Note that these fields are only aliased, not annotated
+ queryset = queryset.alias(
+ total_stock=Coalesce(
+ SubquerySum(
+ 'sub_part__stock_items__quantity',
+ filter=StockItem.IN_STOCK_FILTER
+ ),
+ Decimal(0),
+ output_field=models.DecimalField(),
+ ),
+ allocated_to_sales_orders=Coalesce(
+ SubquerySum(
+ 'sub_part__stock_items__sales_order_allocations__quantity',
+ filter=sales_order_filter,
+ ),
+ Decimal(0),
+ output_field=models.DecimalField(),
+ ),
+ allocated_to_build_orders=Coalesce(
+ SubquerySum(
+ 'sub_part__stock_items__allocations__quantity',
+ filter=build_order_filter,
+ ),
+ Decimal(0),
+ output_field=models.DecimalField(),
+ ),
+ )
+
+ # Calculate 'available_stock' based on previously annotated fields
+ queryset = queryset.annotate(
+ available_stock=ExpressionWrapper(
+ F('total_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'),
+ output_field=models.DecimalField(),
+ )
+ )
+
+ # Extract similar information for any 'substitute' parts
+ queryset = queryset.alias(
+ substitute_stock=Coalesce(
+ SubquerySum(
+ 'substitutes__part__stock_items__quantity',
+ filter=StockItem.IN_STOCK_FILTER,
+ ),
+ Decimal(0),
+ output_field=models.DecimalField(),
+ ),
+ substitute_build_allocations=Coalesce(
+ SubquerySum(
+ 'substitutes__part__stock_items__allocations__quantity',
+ filter=build_order_filter,
+ ),
+ Decimal(0),
+ output_field=models.DecimalField(),
+ ),
+ substitute_sales_allocations=Coalesce(
+ SubquerySum(
+ 'substitutes__part__stock_items__sales_order_allocations__quantity',
+ filter=sales_order_filter,
+ ),
+ Decimal(0),
+ output_field=models.DecimalField(),
+ ),
+ )
+
+ # Calculate 'available_variant_stock' field
+ queryset = queryset.annotate(
+ available_substitute_stock=ExpressionWrapper(
+ F('substitute_stock') - F('substitute_build_allocations') - F('substitute_sales_allocations'),
+ output_field=models.DecimalField(),
+ )
+ )
+
+ return queryset
+
def get_purchase_price_range(self, obj):
""" Return purchase price range """
@@ -682,6 +786,10 @@ class BomItemSerializer(InvenTreeModelSerializer):
'substitutes',
'price_range',
'validated',
+
+ # Annotated fields describing available quantity
+ 'available_stock',
+ 'available_substitute_stock',
]
diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py
index bea7154612..5a8acbecd9 100644
--- a/InvenTree/part/test_api.py
+++ b/InvenTree/part/test_api.py
@@ -9,7 +9,7 @@ from rest_framework import status
from rest_framework.test import APIClient
from InvenTree.api_tester import InvenTreeAPITestCase
-from InvenTree.status_codes import BuildStatus, StockStatus
+from InvenTree.status_codes import BuildStatus, StockStatus, PurchaseOrderStatus
from part.models import Part, PartCategory
from part.models import BomItem, BomItemSubstitute
@@ -578,7 +578,12 @@ class PartDetailTests(InvenTreeAPITestCase):
'part',
'location',
'bom',
+ 'company',
'test_templates',
+ 'manufacturer_part',
+ 'supplier_part',
+ 'order',
+ 'stock',
]
roles = [
@@ -805,6 +810,38 @@ class PartDetailTests(InvenTreeAPITestCase):
# And now check that the image has been set
p = Part.objects.get(pk=pk)
+ def test_details(self):
+ """
+ Test that the required details are available
+ """
+
+ p = Part.objects.get(pk=1)
+
+ url = reverse('api-part-detail', kwargs={'pk': 1})
+
+ data = self.get(url, expected_code=200).data
+
+ # How many parts are 'on order' for this part?
+ lines = order.models.PurchaseOrderLineItem.objects.filter(
+ part__part__pk=1,
+ order__status__in=PurchaseOrderStatus.OPEN,
+ )
+
+ on_order = 0
+
+ # Calculate the "on_order" quantity by hand,
+ # to check it matches the API value
+ for line in lines:
+ on_order += line.quantity
+ on_order -= line.received
+
+ self.assertEqual(on_order, data['ordering'])
+ self.assertEqual(on_order, p.on_order)
+
+ # Some other checks
+ self.assertEqual(data['in_stock'], 9000)
+ self.assertEqual(data['unallocated_stock'], 9000)
+
class PartAPIAggregationTest(InvenTreeAPITestCase):
"""
@@ -1123,6 +1160,12 @@ class BomItemTest(InvenTreeAPITestCase):
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['pk'], bom_item.pk)
+ # Each item in response should contain expected keys
+ for el in response.data:
+
+ for key in ['available_stock', 'available_substitute_stock']:
+ self.assertTrue(key in el)
+
def test_get_bom_detail(self):
"""
Get the detail view for a single BomItem object
@@ -1132,6 +1175,26 @@ class BomItemTest(InvenTreeAPITestCase):
response = self.get(url, expected_code=200)
+ expected_values = [
+ 'allow_variants',
+ 'inherited',
+ 'note',
+ 'optional',
+ 'overage',
+ 'pk',
+ 'part',
+ 'quantity',
+ 'reference',
+ 'sub_part',
+ 'substitutes',
+ 'validated',
+ 'available_stock',
+ 'available_substitute_stock',
+ ]
+
+ for key in expected_values:
+ self.assertTrue(key in response.data)
+
self.assertEqual(int(float(response.data['quantity'])), 25)
# Increase the quantity
@@ -1319,6 +1382,21 @@ class BomItemTest(InvenTreeAPITestCase):
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 5)
+ # The BomItem detail endpoint should now also reflect the substitute data
+ data = self.get(
+ reverse('api-bom-item-detail', kwargs={'pk': bom_item.pk}),
+ expected_code=200
+ ).data
+
+ # 5 substitute parts
+ self.assertEqual(len(data['substitutes']), 5)
+
+ # 5 x 1,000 stock quantity
+ self.assertEqual(data['available_substitute_stock'], 5000)
+
+ # 9,000 stock directly available
+ self.assertEqual(data['available_stock'], 9000)
+
def test_bom_item_uses(self):
"""
Tests for the 'uses' field
diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js
index 93e1562a38..6591068e9d 100644
--- a/InvenTree/templates/js/translated/bom.js
+++ b/InvenTree/templates/js/translated/bom.js
@@ -798,17 +798,25 @@ function loadBomTable(table, options={}) {
});
cols.push({
- field: 'sub_part_detail.stock',
+ field: 'available_stock',
title: '{% trans "Available" %}',
searchable: false,
sortable: true,
formatter: function(value, row) {
var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
- var text = value;
- if (value == null || value <= 0) {
- text = `{% trans "No Stock" %}`;
+ // Calculate total "available" (unallocated) quantity
+ var total = row.available_stock + row.available_substitute_stock;
+
+ var text = `${total}`;
+
+ if (total <= 0) {
+ text = `{% trans "No Stock Available" %}`;
+ } else {
+ if (row.available_substitute_stock > 0) {
+ text += ``;
+ }
}
return renderLink(text, url);
@@ -902,8 +910,10 @@ function loadBomTable(table, options={}) {
formatter: function(value, row) {
var can_build = 0;
+ var available = row.available_stock + row.available_substitute_stock;
+
if (row.quantity > 0) {
- can_build = row.sub_part_detail.stock / row.quantity;
+ can_build = available / row.quantity;
}
return +can_build.toFixed(2);
@@ -914,11 +924,11 @@ function loadBomTable(table, options={}) {
var cb_b = 0;
if (rowA.quantity > 0) {
- cb_a = rowA.sub_part_detail.stock / rowA.quantity;
+ cb_a = (rowA.available_stock + rowA.available_substitute_stock) / rowA.quantity;
}
if (rowB.quantity > 0) {
- cb_b = rowB.sub_part_detail.stock / rowB.quantity;
+ cb_b = (rowB.available_stock + rowB.available_substitute_stock) / rowB.quantity;
}
return (cb_a > cb_b) ? 1 : -1;
diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index d4db965ebd..ff1f475d5d 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -1421,9 +1421,24 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
sortable: true,
},
{
- field: 'sub_part_detail.stock',
+ field: 'available_stock',
title: '{% trans "Available" %}',
sortable: true,
+ formatter: function(value, row) {
+ var total = row.available_stock + row.available_substitute_stock;
+
+ var text = `${total}`;
+
+ if (total <= 0) {
+ text = `{% trans "No Stock Available" %}`;
+ } else {
+ if (row.available_substitute_stock > 0) {
+ text += ``;
+ }
+ }
+
+ return text;
+ }
},
{
field: 'allocated',