mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 21:25:42 +00:00 
			
		
		
		
	Merge pull request #2806 from SchrodingersGat/bom-serializer-quantity
Bom serializer quantity
This commit is contained in:
		| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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 """ | ||||
|   | ||||
| @@ -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 | ||||
|         """ | ||||
|   | ||||
| @@ -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', | ||||
|         ] | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock" %}</span>`; | ||||
|             // Calculate total "available" (unallocated) quantity | ||||
|             var total = row.available_stock + row.available_substitute_stock; | ||||
|  | ||||
|             var text = `${total}`; | ||||
|  | ||||
|             if (total <= 0) { | ||||
|                 text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`; | ||||
|             } else { | ||||
|                 if (row.available_substitute_stock > 0) { | ||||
|                     text += `<span title='{% trans "Includes substitute stock" %}' class='fas fa-info-circle float-right icon-blue'></span>`; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             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; | ||||
|   | ||||
| @@ -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 = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`; | ||||
|                     } else { | ||||
|                         if (row.available_substitute_stock > 0) { | ||||
|                             text += `<span title='{% trans "Includes substitute stock" %}' class='fas fa-info-circle float-right icon-blue'></span>`; | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     return text; | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 field: 'allocated', | ||||
|   | ||||
		Reference in New Issue
	
	Block a user