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',