mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	Negative annotations (#9909)
* Fix for "annotated_scheduled_to_build_quantity" * Further annotation updates * Add unit testing for annotation * Tweak unit test * Cast annotated expression * Specific fields required to work * Refactor other instances * Update API docs
This commit is contained in:
		| @@ -1,12 +1,15 @@ | ||||
| """InvenTree API version information.""" | ||||
|  | ||||
| # InvenTree API version | ||||
| INVENTREE_API_VERSION = 358 | ||||
| INVENTREE_API_VERSION = 359 | ||||
|  | ||||
| """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" | ||||
|  | ||||
| INVENTREE_API_TEXT = """ | ||||
|  | ||||
| v359 -> 2025-07-01 : https://github.com/inventree/InvenTree/pull/9909 | ||||
|     - Fixes annotated types for various part fields | ||||
|  | ||||
| v358 -> 2025-06-25 : https://github.com/inventree/InvenTree/pull/9857 | ||||
|     - Provide list of generated stock items against "StockItemSerialize" API endpoint | ||||
|     - Provide list of generated stock items against "StockList" API endpoint | ||||
|   | ||||
| @@ -15,7 +15,7 @@ from django.db.models import ( | ||||
|     Value, | ||||
|     When, | ||||
| ) | ||||
| from django.db.models.functions import Coalesce | ||||
| from django.db.models.functions import Coalesce, Greatest | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
| from rest_framework import serializers | ||||
| @@ -1566,10 +1566,14 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali | ||||
|  | ||||
|         # 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'), | ||||
|             available_stock=Greatest( | ||||
|                 ExpressionWrapper( | ||||
|                     F('total_stock') | ||||
|                     - F('allocated_to_sales_orders') | ||||
|                     - F('allocated_to_build_orders'), | ||||
|                     output_field=models.DecimalField(), | ||||
|                 ), | ||||
|                 0, | ||||
|                 output_field=models.DecimalField(), | ||||
|             ) | ||||
|         ) | ||||
| @@ -1603,10 +1607,14 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali | ||||
|  | ||||
|         # Calculate 'available_substitute_stock' field | ||||
|         queryset = queryset.annotate( | ||||
|             available_substitute_stock=ExpressionWrapper( | ||||
|                 F('substitute_stock') | ||||
|                 - F('substitute_build_allocations') | ||||
|                 - F('substitute_sales_allocations'), | ||||
|             available_substitute_stock=Greatest( | ||||
|                 ExpressionWrapper( | ||||
|                     F('substitute_stock') | ||||
|                     - F('substitute_build_allocations') | ||||
|                     - F('substitute_sales_allocations'), | ||||
|                     output_field=models.DecimalField(), | ||||
|                 ), | ||||
|                 0, | ||||
|                 output_field=models.DecimalField(), | ||||
|             ) | ||||
|         ) | ||||
| @@ -1629,10 +1637,14 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali | ||||
|         ) | ||||
|  | ||||
|         queryset = queryset.annotate( | ||||
|             available_variant_stock=ExpressionWrapper( | ||||
|                 F('variant_stock_total') | ||||
|                 - F('variant_bo_allocations') | ||||
|                 - F('variant_so_allocations'), | ||||
|             available_variant_stock=Greatest( | ||||
|                 ExpressionWrapper( | ||||
|                     F('variant_stock_total') | ||||
|                     - F('variant_bo_allocations') | ||||
|                     - F('variant_so_allocations'), | ||||
|                     output_field=FloatField(), | ||||
|                 ), | ||||
|                 0, | ||||
|                 output_field=FloatField(), | ||||
|             ) | ||||
|         ) | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
| from decimal import Decimal | ||||
|  | ||||
| from django.db.models import DecimalField, ExpressionWrapper, F, Q | ||||
| from django.db.models.functions import Coalesce | ||||
| from django.db.models.functions import Coalesce, Greatest | ||||
|  | ||||
| from sql_util.utils import SubquerySum | ||||
|  | ||||
| @@ -24,9 +24,13 @@ def annotate_on_order_quantity(): | ||||
|  | ||||
|     return Coalesce( | ||||
|         SubquerySum( | ||||
|             ExpressionWrapper( | ||||
|                 F('purchase_order_line_items__quantity') | ||||
|                 - F('purchase_order_line_items__received'), | ||||
|             Greatest( | ||||
|                 ExpressionWrapper( | ||||
|                     F('purchase_order_line_items__quantity') | ||||
|                     - F('purchase_order_line_items__received'), | ||||
|                     output_field=DecimalField(), | ||||
|                 ), | ||||
|                 0, | ||||
|                 output_field=DecimalField(), | ||||
|             ), | ||||
|             filter=order_filter, | ||||
|   | ||||
| @@ -14,7 +14,7 @@ from django.db.models import ( | ||||
|     Value, | ||||
|     When, | ||||
| ) | ||||
| from django.db.models.functions import Coalesce | ||||
| from django.db.models.functions import Coalesce, Greatest | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
| from rest_framework import serializers | ||||
| @@ -1205,10 +1205,14 @@ class SalesOrderLineItemSerializer( | ||||
|         ) | ||||
|  | ||||
|         queryset = queryset.annotate( | ||||
|             available_stock=ExpressionWrapper( | ||||
|                 F('total_stock') | ||||
|                 - F('allocated_to_sales_orders') | ||||
|                 - F('allocated_to_build_orders'), | ||||
|             available_stock=Greatest( | ||||
|                 ExpressionWrapper( | ||||
|                     F('total_stock') | ||||
|                     - F('allocated_to_sales_orders') | ||||
|                     - F('allocated_to_build_orders'), | ||||
|                     output_field=models.DecimalField(), | ||||
|                 ), | ||||
|                 0, | ||||
|                 output_field=models.DecimalField(), | ||||
|             ) | ||||
|         ) | ||||
| @@ -1232,10 +1236,14 @@ class SalesOrderLineItemSerializer( | ||||
|         ) | ||||
|  | ||||
|         queryset = queryset.annotate( | ||||
|             available_variant_stock=ExpressionWrapper( | ||||
|                 F('variant_stock_total') | ||||
|                 - F('variant_bo_allocations') | ||||
|                 - F('variant_so_allocations'), | ||||
|             available_variant_stock=Greatest( | ||||
|                 ExpressionWrapper( | ||||
|                     F('variant_stock_total') | ||||
|                     - F('variant_bo_allocations') | ||||
|                     - F('variant_so_allocations'), | ||||
|                     output_field=models.DecimalField(), | ||||
|                 ), | ||||
|                 0, | ||||
|                 output_field=models.DecimalField(), | ||||
|             ) | ||||
|         ) | ||||
|   | ||||
| @@ -29,7 +29,7 @@ from django.db.models import ( | ||||
|     Value, | ||||
|     When, | ||||
| ) | ||||
| from django.db.models.functions import Coalesce | ||||
| from django.db.models.functions import Cast, Coalesce, Greatest | ||||
|  | ||||
| from sql_util.utils import SubquerySum | ||||
|  | ||||
| @@ -73,14 +73,20 @@ def annotate_scheduled_to_build_quantity(reference: str = ''): | ||||
|  | ||||
|     return Coalesce( | ||||
|         SubquerySum( | ||||
|             ExpressionWrapper( | ||||
|                 F(f'{reference}builds__quantity') - F(f'{reference}builds__completed'), | ||||
|                 output_field=DecimalField(), | ||||
|             Greatest( | ||||
|                 ExpressionWrapper( | ||||
|                     Cast(F(f'{reference}builds__quantity'), output_field=IntegerField()) | ||||
|                     - Cast( | ||||
|                         F(f'{reference}builds__completed'), output_field=IntegerField() | ||||
|                     ), | ||||
|                     output_field=IntegerField(), | ||||
|                 ), | ||||
|                 0, | ||||
|             ), | ||||
|             filter=building_filter, | ||||
|         ), | ||||
|         Decimal(0), | ||||
|         output_field=DecimalField(), | ||||
|         0, | ||||
|         output_field=IntegerField(), | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @@ -100,25 +106,30 @@ def annotate_on_order_quantity(reference: str = ''): | ||||
|         order__status__in=PurchaseOrderStatusGroups.OPEN, quantity__gt=F('received') | ||||
|     ) | ||||
|  | ||||
|     return Coalesce( | ||||
|         SubquerySum( | ||||
|             ExpressionWrapper( | ||||
|                 F(f'{reference}supplier_parts__purchase_order_line_items__quantity') | ||||
|                 * F(f'{reference}supplier_parts__pack_quantity_native'), | ||||
|                 output_field=DecimalField(), | ||||
|     return Greatest( | ||||
|         Coalesce( | ||||
|             SubquerySum( | ||||
|                 ExpressionWrapper( | ||||
|                     F(f'{reference}supplier_parts__purchase_order_line_items__quantity') | ||||
|                     * F(f'{reference}supplier_parts__pack_quantity_native'), | ||||
|                     output_field=DecimalField(), | ||||
|                 ), | ||||
|                 filter=order_filter, | ||||
|             ), | ||||
|             filter=order_filter, | ||||
|         ), | ||||
|         Decimal(0), | ||||
|         output_field=DecimalField(), | ||||
|     ) - Coalesce( | ||||
|         SubquerySum( | ||||
|             ExpressionWrapper( | ||||
|                 F(f'{reference}supplier_parts__purchase_order_line_items__received') | ||||
|                 * F(f'{reference}supplier_parts__pack_quantity_native'), | ||||
|                 output_field=DecimalField(), | ||||
|             Decimal(0), | ||||
|             output_field=DecimalField(), | ||||
|         ) | ||||
|         - Coalesce( | ||||
|             SubquerySum( | ||||
|                 ExpressionWrapper( | ||||
|                     F(f'{reference}supplier_parts__purchase_order_line_items__received') | ||||
|                     * F(f'{reference}supplier_parts__pack_quantity_native'), | ||||
|                     output_field=DecimalField(), | ||||
|                 ), | ||||
|                 filter=order_filter, | ||||
|             ), | ||||
|             filter=order_filter, | ||||
|             Decimal(0), | ||||
|             output_field=DecimalField(), | ||||
|         ), | ||||
|         Decimal(0), | ||||
|         output_field=DecimalField(), | ||||
|   | ||||
| @@ -18,7 +18,7 @@ from django.core.exceptions import ValidationError | ||||
| from django.core.validators import MinLengthValidator, MinValueValidator | ||||
| from django.db import models, transaction | ||||
| from django.db.models import ExpressionWrapper, F, Q, QuerySet, Sum, UniqueConstraint | ||||
| from django.db.models.functions import Coalesce | ||||
| from django.db.models.functions import Coalesce, Greatest | ||||
| from django.db.models.signals import post_delete, post_save | ||||
| from django.db.utils import IntegrityError | ||||
| from django.dispatch import receiver | ||||
| @@ -1530,8 +1530,12 @@ class Part( | ||||
|  | ||||
|         # Calculate the 'available stock' based on previous annotations | ||||
|         queryset = queryset.annotate( | ||||
|             available_stock=ExpressionWrapper( | ||||
|                 F('total_stock') - F('so_allocations') - F('bo_allocations'), | ||||
|             available_stock=Greatest( | ||||
|                 ExpressionWrapper( | ||||
|                     F('total_stock') - F('so_allocations') - F('bo_allocations'), | ||||
|                     output_field=models.DecimalField(), | ||||
|                 ), | ||||
|                 0, | ||||
|                 output_field=models.DecimalField(), | ||||
|             ) | ||||
|         ) | ||||
| @@ -1549,10 +1553,14 @@ class Part( | ||||
|         ) | ||||
|  | ||||
|         queryset = queryset.annotate( | ||||
|             substitute_stock=ExpressionWrapper( | ||||
|                 F('sub_total_stock') | ||||
|                 - F('sub_so_allocations') | ||||
|                 - F('sub_bo_allocations'), | ||||
|             substitute_stock=Greatest( | ||||
|                 ExpressionWrapper( | ||||
|                     F('sub_total_stock') | ||||
|                     - F('sub_so_allocations') | ||||
|                     - F('sub_bo_allocations'), | ||||
|                     output_field=models.DecimalField(), | ||||
|                 ), | ||||
|                 0, | ||||
|                 output_field=models.DecimalField(), | ||||
|             ) | ||||
|         ) | ||||
| @@ -1573,10 +1581,14 @@ class Part( | ||||
|         ) | ||||
|  | ||||
|         queryset = queryset.annotate( | ||||
|             variant_stock=ExpressionWrapper( | ||||
|                 F('var_total_stock') | ||||
|                 - F('var_bo_allocations') | ||||
|                 - F('var_so_allocations'), | ||||
|             variant_stock=Greatest( | ||||
|                 ExpressionWrapper( | ||||
|                     F('var_total_stock') | ||||
|                     - F('var_bo_allocations') | ||||
|                     - F('var_so_allocations'), | ||||
|                     output_field=models.DecimalField(), | ||||
|                 ), | ||||
|                 0, | ||||
|                 output_field=models.DecimalField(), | ||||
|             ) | ||||
|         ) | ||||
|   | ||||
| @@ -9,7 +9,7 @@ from django.core.files.base import ContentFile | ||||
| from django.core.validators import MinValueValidator | ||||
| from django.db import IntegrityError, models, transaction | ||||
| from django.db.models import ExpressionWrapper, F, FloatField, Q | ||||
| from django.db.models.functions import Coalesce | ||||
| from django.db.models.functions import Coalesce, Greatest | ||||
| from django.urls import reverse_lazy | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
| @@ -843,10 +843,14 @@ class PartSerializer( | ||||
|         # Annotate with the total 'available stock' quantity | ||||
|         # This is the current stock, minus any allocations | ||||
|         queryset = queryset.annotate( | ||||
|             unallocated_stock=ExpressionWrapper( | ||||
|                 F('total_in_stock') | ||||
|                 - F('allocated_to_sales_orders') | ||||
|                 - F('allocated_to_build_orders'), | ||||
|             unallocated_stock=Greatest( | ||||
|                 ExpressionWrapper( | ||||
|                     F('total_in_stock') | ||||
|                     - F('allocated_to_sales_orders') | ||||
|                     - F('allocated_to_build_orders'), | ||||
|                     output_field=models.DecimalField(), | ||||
|                 ), | ||||
|                 Decimal(0), | ||||
|                 output_field=models.DecimalField(), | ||||
|             ) | ||||
|         ) | ||||
| @@ -1226,7 +1230,7 @@ class PartRequirementsSerializer(InvenTree.serializers.InvenTreeModelSerializer) | ||||
|         read_only=True, label=_('In Production'), source='quantity_in_production' | ||||
|     ) | ||||
|  | ||||
|     scheduled_to_build = serializers.FloatField( | ||||
|     scheduled_to_build = serializers.IntegerField( | ||||
|         read_only=True, label=_('Scheduled to Build'), source='quantity_being_built' | ||||
|     ) | ||||
|  | ||||
| @@ -1866,10 +1870,14 @@ class BomItemSerializer( | ||||
|  | ||||
|         # 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'), | ||||
|             available_stock=Greatest( | ||||
|                 ExpressionWrapper( | ||||
|                     F('total_stock') | ||||
|                     - F('allocated_to_sales_orders') | ||||
|                     - F('allocated_to_build_orders'), | ||||
|                     output_field=models.DecimalField(), | ||||
|                 ), | ||||
|                 Decimal(0), | ||||
|                 output_field=models.DecimalField(), | ||||
|             ) | ||||
|         ) | ||||
| @@ -1896,10 +1904,14 @@ class BomItemSerializer( | ||||
|  | ||||
|         # Calculate 'available_substitute_stock' field | ||||
|         queryset = queryset.annotate( | ||||
|             available_substitute_stock=ExpressionWrapper( | ||||
|                 F('substitute_stock') | ||||
|                 - F('substitute_build_allocations') | ||||
|                 - F('substitute_sales_allocations'), | ||||
|             available_substitute_stock=Greatest( | ||||
|                 ExpressionWrapper( | ||||
|                     F('substitute_stock') | ||||
|                     - F('substitute_build_allocations') | ||||
|                     - F('substitute_sales_allocations'), | ||||
|                     output_field=models.DecimalField(), | ||||
|                 ), | ||||
|                 Decimal(0), | ||||
|                 output_field=models.DecimalField(), | ||||
|             ) | ||||
|         ) | ||||
| @@ -1920,10 +1932,14 @@ class BomItemSerializer( | ||||
|         ) | ||||
|  | ||||
|         queryset = queryset.annotate( | ||||
|             available_variant_stock=ExpressionWrapper( | ||||
|                 F('variant_stock_total') | ||||
|                 - F('variant_bo_allocations') | ||||
|                 - F('variant_so_allocations'), | ||||
|             available_variant_stock=Greatest( | ||||
|                 ExpressionWrapper( | ||||
|                     F('variant_stock_total') | ||||
|                     - F('variant_bo_allocations') | ||||
|                     - F('variant_so_allocations'), | ||||
|                     output_field=FloatField(), | ||||
|                 ), | ||||
|                 0, | ||||
|                 output_field=FloatField(), | ||||
|             ) | ||||
|         ) | ||||
|   | ||||
| @@ -1999,7 +1999,7 @@ class PartPricingDetailTests(InvenTreeAPITestCase): | ||||
|  | ||||
|  | ||||
| class PartAPIAggregationTest(InvenTreeAPITestCase): | ||||
|     """Tests to ensure that the various aggregation annotations are working correctly...""" | ||||
|     """Tests to ensure that the various aggregation annotations are working correctly.""" | ||||
|  | ||||
|     fixtures = [ | ||||
|         'category', | ||||
| @@ -2303,6 +2303,70 @@ class PartAPIAggregationTest(InvenTreeAPITestCase): | ||||
|             # The annotated quantity must also match the part.on_order quantity | ||||
|             self.assertEqual(on_order, p.on_order) | ||||
|  | ||||
|     def test_building(self): | ||||
|         """Test the 'building' quantity annotations.""" | ||||
|         # Create a new "buildable" part | ||||
|         part = Part.objects.create( | ||||
|             name='Buildable Part', | ||||
|             description='A part which can be built', | ||||
|             category=PartCategory.objects.get(pk=1), | ||||
|             assembly=True, | ||||
|         ) | ||||
|  | ||||
|         url = reverse('api-part-detail', kwargs={'pk': part.pk}) | ||||
|  | ||||
|         # Initially, no quantity in production | ||||
|         data = self.get(url).data | ||||
|  | ||||
|         self.assertEqual(data['building'], 0) | ||||
|         self.assertEqual(data['scheduled_to_build'], 0) | ||||
|  | ||||
|         # Create some builds for this part | ||||
|         builds = [] | ||||
|         for idx in range(3): | ||||
|             builds.append( | ||||
|                 build.models.Build.objects.create( | ||||
|                     part=part, | ||||
|                     quantity=10 * (idx + 1), | ||||
|                     title=f'Build {idx + 1}', | ||||
|                     reference=f'BO-{idx + 999}', | ||||
|                 ) | ||||
|             ) | ||||
|  | ||||
|         data = self.get(url).data | ||||
|  | ||||
|         # There should now be 60 "scheduled", but nothing currently "building" | ||||
|         self.assertEqual(data['building'], 0) | ||||
|         self.assertEqual(data['scheduled_to_build'], 60) | ||||
|  | ||||
|         # Update the "completed" count for the first build | ||||
|         # Even though this build will be "negative" it should not affect the other builds | ||||
|         # The "scheduled_to_build" count should reduce by 10, not 9999 | ||||
|         builds[0].completed = 9999 | ||||
|         builds[0].save() | ||||
|  | ||||
|         data = self.get(url).data | ||||
|  | ||||
|         self.assertEqual(data['building'], 0) | ||||
|         self.assertEqual(data['scheduled_to_build'], 50) | ||||
|  | ||||
|         # Create some "in production" items against the third build | ||||
|         for idx in range(10): | ||||
|             StockItem.objects.create( | ||||
|                 part=part, build=builds[2], quantity=(1 + idx), is_building=True | ||||
|             ) | ||||
|  | ||||
|         # Let's also update the "completeed" count | ||||
|         builds[1].completed = 5 | ||||
|         builds[1].save() | ||||
|         builds[2].completed = 13 | ||||
|         builds[2].save() | ||||
|  | ||||
|         data = self.get(url).data | ||||
|  | ||||
|         self.assertEqual(data['building'], 55) | ||||
|         self.assertEqual(data['scheduled_to_build'], 32) | ||||
|  | ||||
|  | ||||
| class BomItemTest(InvenTreeAPITestCase): | ||||
|     """Unit tests for the BomItem API.""" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user