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`); } } ];