diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py
index f767148199..a49209dfe7 100644
--- a/InvenTree/InvenTree/api_version.py
+++ b/InvenTree/InvenTree/api_version.py
@@ -4,11 +4,14 @@ InvenTree API version information
# InvenTree API version
-INVENTREE_API_VERSION = 41
+INVENTREE_API_VERSION = 42
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
+v42 -> 2022-04-26 : https://github.com/inventree/InvenTree/pull/2833
+ - Adds variant stock information to the Part and BomItem serializers
+
v41 -> 2022-04-26
- Fixes 'variant_of' filter for Part list endpoint
diff --git a/InvenTree/part/fixtures/bom.yaml b/InvenTree/part/fixtures/bom.yaml
index facb7e76ae..ac52452d75 100644
--- a/InvenTree/part/fixtures/bom.yaml
+++ b/InvenTree/part/fixtures/bom.yaml
@@ -7,6 +7,7 @@
part: 100
sub_part: 1
quantity: 10
+ allow_variants: True
# 40 x R_2K2_0805
- model: part.bomitem
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index 8bf3d77501..387d3df1d5 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -7,7 +7,9 @@ from decimal import Decimal
from django.urls import reverse_lazy
from django.db import models, transaction
-from django.db.models import ExpressionWrapper, F, Q
+from django.db.models import ExpressionWrapper, F, Q, Func
+from django.db.models import Subquery, OuterRef, FloatField
+
from django.db.models.functions import Coalesce
from django.utils.translation import ugettext_lazy as _
@@ -308,9 +310,6 @@ class PartSerializer(InvenTreeModelSerializer):
to reduce database trips.
"""
- # TODO: Update the "in_stock" annotation to include stock for variants of the part
- # Ref: https://github.com/inventree/InvenTree/issues/2240
-
# Annotate with the total 'in stock' quantity
queryset = queryset.annotate(
in_stock=Coalesce(
@@ -325,6 +324,24 @@ class PartSerializer(InvenTreeModelSerializer):
stock_item_count=SubqueryCount('stock_items')
)
+ # Annotate with the total variant stock quantity
+ variant_query = StockItem.objects.filter(
+ part__tree_id=OuterRef('tree_id'),
+ part__lft__gt=OuterRef('lft'),
+ part__rght__lt=OuterRef('rght'),
+ ).filter(StockItem.IN_STOCK_FILTER)
+
+ queryset = queryset.annotate(
+ variant_stock=Coalesce(
+ Subquery(
+ variant_query.annotate(
+ total=Func(F('quantity'), function='SUM', output_field=FloatField())
+ ).values('total')),
+ 0,
+ output_field=FloatField(),
+ )
+ )
+
# Filter to limit builds to "active"
build_filter = Q(
status__in=BuildStatus.ACTIVE_CODES
@@ -429,6 +446,7 @@ class PartSerializer(InvenTreeModelSerializer):
unallocated_stock = serializers.FloatField(read_only=True)
building = serializers.FloatField(read_only=True)
in_stock = serializers.FloatField(read_only=True)
+ variant_stock = serializers.FloatField(read_only=True)
ordering = serializers.FloatField(read_only=True)
stock_item_count = serializers.IntegerField(read_only=True)
suppliers = serializers.IntegerField(read_only=True)
@@ -463,6 +481,7 @@ class PartSerializer(InvenTreeModelSerializer):
'full_name',
'image',
'in_stock',
+ 'variant_stock',
'ordering',
'building',
'IPN',
@@ -577,9 +596,10 @@ class BomItemSerializer(InvenTreeModelSerializer):
purchase_price_range = serializers.SerializerMethodField()
- # Annotated fields
+ # Annotated fields for available stock
available_stock = serializers.FloatField(read_only=True)
available_substitute_stock = serializers.FloatField(read_only=True)
+ available_variant_stock = serializers.FloatField(read_only=True)
def __init__(self, *args, **kwargs):
# part_detail and sub_part_detail serializers are only included if requested.
@@ -613,11 +633,18 @@ class BomItemSerializer(InvenTreeModelSerializer):
queryset = queryset.prefetch_related('sub_part')
queryset = queryset.prefetch_related('sub_part__category')
+
queryset = queryset.prefetch_related(
'sub_part__stock_items',
'sub_part__stock_items__allocations',
'sub_part__stock_items__sales_order_allocations',
)
+
+ queryset = queryset.prefetch_related(
+ 'substitutes',
+ 'substitutes__part__stock_items',
+ )
+
queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks')
return queryset
@@ -707,7 +734,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
),
)
- # Calculate 'available_variant_stock' field
+ # Calculate 'available_substitute_stock' field
queryset = queryset.annotate(
available_substitute_stock=ExpressionWrapper(
F('substitute_stock') - F('substitute_build_allocations') - F('substitute_sales_allocations'),
@@ -715,6 +742,47 @@ class BomItemSerializer(InvenTreeModelSerializer):
)
)
+ # Annotate the queryset with 'available variant stock' information
+ variant_stock_query = StockItem.objects.filter(
+ part__tree_id=OuterRef('sub_part__tree_id'),
+ part__lft__gt=OuterRef('sub_part__lft'),
+ part__rght__lt=OuterRef('sub_part__rght'),
+ ).filter(StockItem.IN_STOCK_FILTER)
+
+ queryset = queryset.alias(
+ variant_stock_total=Coalesce(
+ Subquery(
+ variant_stock_query.annotate(
+ total=Func(F('quantity'), function='SUM', output_field=FloatField())
+ ).values('total')),
+ 0,
+ output_field=FloatField()
+ ),
+ variant_stock_build_order_allocations=Coalesce(
+ Subquery(
+ variant_stock_query.annotate(
+ total=Func(F('sales_order_allocations__quantity'), function='SUM', output_field=FloatField()),
+ ).values('total')),
+ 0,
+ output_field=FloatField(),
+ ),
+ variant_stock_sales_order_allocations=Coalesce(
+ Subquery(
+ variant_stock_query.annotate(
+ total=Func(F('allocations__quantity'), function='SUM', output_field=FloatField()),
+ ).values('total')),
+ 0,
+ output_field=FloatField(),
+ )
+ )
+
+ queryset = queryset.annotate(
+ available_variant_stock=ExpressionWrapper(
+ F('variant_stock_total') - F('variant_stock_build_order_allocations') - F('variant_stock_sales_order_allocations'),
+ output_field=FloatField(),
+ )
+ )
+
return queryset
def get_purchase_price_range(self, obj):
@@ -790,6 +858,8 @@ class BomItemSerializer(InvenTreeModelSerializer):
# Annotated fields describing available quantity
'available_stock',
'available_substitute_stock',
+ 'available_variant_stock',
+
]
diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py
index ddfaa44e94..f0770eb1f5 100644
--- a/InvenTree/part/test_api.py
+++ b/InvenTree/part/test_api.py
@@ -658,6 +658,94 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertEqual(len(response.data), 101)
+ def test_variant_stock(self):
+ """
+ Unit tests for the 'variant_stock' annotation,
+ which provides a stock count for *variant* parts
+ """
+
+ # Ensure the MPTT structure is in a known state before running tests
+ Part.objects.rebuild()
+
+ # Initially, there are no "chairs" in stock,
+ # so each 'chair' template should report variant_stock=0
+ url = reverse('api-part-list')
+
+ # Look at the "detail" URL for the master chair template
+ response = self.get('/api/part/10000/', {}, expected_code=200)
+
+ # This part should report 'zero' as variant stock
+ self.assertEqual(response.data['variant_stock'], 0)
+
+ # Grab a list of all variant chairs *under* the master template
+ response = self.get(
+ url,
+ {
+ 'ancestor': 10000,
+ },
+ expected_code=200,
+ )
+
+ # 4 total descendants
+ self.assertEqual(len(response.data), 4)
+
+ for variant in response.data:
+ self.assertEqual(variant['variant_stock'], 0)
+
+ # Now, let's make some variant stock
+ for variant in Part.objects.get(pk=10000).get_descendants(include_self=False):
+ StockItem.objects.create(
+ part=variant,
+ quantity=100,
+ )
+
+ response = self.get('/api/part/10000/', {}, expected_code=200)
+
+ self.assertEqual(response.data['in_stock'], 0)
+ self.assertEqual(response.data['variant_stock'], 400)
+
+ # Check that each variant reports the correct stock quantities
+ response = self.get(
+ url,
+ {
+ 'ancestor': 10000,
+ },
+ expected_code=200,
+ )
+
+ expected_variant_stock = {
+ 10001: 0,
+ 10002: 0,
+ 10003: 100,
+ 10004: 0,
+ }
+
+ for variant in response.data:
+ self.assertEqual(variant['in_stock'], 100)
+ self.assertEqual(variant['variant_stock'], expected_variant_stock[variant['pk']])
+
+ # Add some 'sub variants' for the green chair variant
+ green_chair = Part.objects.get(pk=10004)
+
+ for i in range(10):
+ gcv = Part.objects.create(
+ name=f"GC Var {i}",
+ description="Green chair variant",
+ variant_of=green_chair,
+ )
+
+ StockItem.objects.create(
+ part=gcv,
+ quantity=50,
+ )
+
+ # Spot check of some values
+ response = self.get('/api/part/10000/', {})
+ self.assertEqual(response.data['variant_stock'], 900)
+
+ response = self.get('/api/part/10004/', {})
+ self.assertEqual(response.data['variant_stock'], 500)
+
class PartDetailTests(InvenTreeAPITestCase):
"""
@@ -1541,6 +1629,44 @@ class BomItemTest(InvenTreeAPITestCase):
self.assertEqual(len(response.data), i)
+ def test_bom_variant_stock(self):
+ """
+ Test for 'available_variant_stock' annotation
+ """
+
+ Part.objects.rebuild()
+
+ # BOM item we are interested in
+ bom_item = BomItem.objects.get(pk=1)
+
+ response = self.get('/api/bom/1/', {}, expected_code=200)
+
+ # Initially, no variant stock available
+ self.assertEqual(response.data['available_variant_stock'], 0)
+
+ # Create some 'variants' of the referenced sub_part
+ bom_item.sub_part.is_template = True
+ bom_item.sub_part.save()
+
+ for i in range(10):
+ # Create a variant part
+ vp = Part.objects.create(
+ name=f"Var {i}",
+ description="Variant part",
+ variant_of=bom_item.sub_part,
+ )
+
+ # Create a stock item
+ StockItem.objects.create(
+ part=vp,
+ quantity=100,
+ )
+
+ # There should now be variant stock available
+ response = self.get('/api/bom/1/', {}, expected_code=200)
+
+ self.assertEqual(response.data['available_variant_stock'], 1000)
+
class PartParameterTest(InvenTreeAPITestCase):
"""
diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js
index 6591068e9d..0d27a5e028 100644
--- a/InvenTree/templates/js/translated/bom.js
+++ b/InvenTree/templates/js/translated/bom.js
@@ -807,15 +807,28 @@ function loadBomTable(table, options={}) {
var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
// Calculate total "available" (unallocated) quantity
- var total = row.available_stock + row.available_substitute_stock;
+ var base_stock = row.available_stock;
+ var substitute_stock = row.available_substitute_stock || 0;
+ var variant_stock = row.allow_variants ? (row.available_variant_stock || 0) : 0;
- var text = `${total}`;
+ var available_stock = base_stock + substitute_stock + variant_stock;
+
+ var text = `${available_stock}`;
- if (total <= 0) {
+ if (available_stock <= 0) {
text = `{% trans "No Stock Available" %}`;
} else {
- if (row.available_substitute_stock > 0) {
- text += ``;
+ var extra = '';
+ if ((substitute_stock > 0) && (variant_stock > 0)) {
+ extra = '{% trans "Includes variant and substitute stock" %}';
+ } else if (variant_stock > 0) {
+ extra = '{% trans "Includes variant stock" %}';
+ } else if (substitute_stock > 0) {
+ extra = '{% trans "Includes substitute stock" %}';
+ }
+
+ if (extra) {
+ text += ``;
}
}
@@ -910,7 +923,7 @@ function loadBomTable(table, options={}) {
formatter: function(value, row) {
var can_build = 0;
- var available = row.available_stock + row.available_substitute_stock;
+ var available = row.available_stock + (row.available_substitute_stock || 0) + (row.available_variant_stock || 0);
if (row.quantity > 0) {
can_build = available / row.quantity;
diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index ff1f475d5d..eb955d7ff0 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -1425,19 +1425,36 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
title: '{% trans "Available" %}',
sortable: true,
formatter: function(value, row) {
- var total = row.available_stock + row.available_substitute_stock;
- var text = `${total}`;
+ var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
- if (total <= 0) {
+ // Calculate total "available" (unallocated) quantity
+ var base_stock = row.available_stock;
+ var substitute_stock = row.available_substitute_stock || 0;
+ var variant_stock = row.allow_variants ? (row.available_variant_stock || 0) : 0;
+
+ var available_stock = base_stock + substitute_stock + variant_stock;
+
+ var text = `${available_stock}`;
+
+ if (available_stock <= 0) {
text = `{% trans "No Stock Available" %}`;
} else {
- if (row.available_substitute_stock > 0) {
- text += ``;
+ var extra = '';
+ if ((substitute_stock > 0) && (variant_stock > 0)) {
+ extra = '{% trans "Includes variant and substitute stock" %}';
+ } else if (variant_stock > 0) {
+ extra = '{% trans "Includes variant stock" %}';
+ } else if (substitute_stock > 0) {
+ extra = '{% trans "Includes substitute stock" %}';
+ }
+
+ if (extra) {
+ text += ``;
}
}
-
- return text;
+
+ return renderLink(text, url);
}
},
{
diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js
index 1a8e728040..35d5d0d5a6 100644
--- a/InvenTree/templates/js/translated/part.js
+++ b/InvenTree/templates/js/translated/part.js
@@ -672,7 +672,20 @@ function loadPartVariantTable(table, partId, options={}) {
field: 'in_stock',
title: '{% trans "Stock" %}',
formatter: function(value, row) {
- return renderLink(value, `/part/${row.pk}/?display=part-stock`);
+
+ var base_stock = row.in_stock;
+ var variant_stock = row.variant_stock || 0;
+
+ var total = base_stock + variant_stock;
+
+ var text = `${total}`;
+
+ if (variant_stock > 0) {
+ text = `${text}`;
+ text += ``;
+ }
+
+ return renderLink(text, `/part/${row.pk}/?display=part-stock`);
}
}
];