From 83b524f8081704c951cc597717a4ed1dae43faeb Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 1 Jul 2025 08:57:27 +1000 Subject: [PATCH] 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 --- .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/build/serializers.py | 38 +++++++---- src/backend/InvenTree/company/filters.py | 12 ++-- src/backend/InvenTree/order/serializers.py | 26 +++++--- src/backend/InvenTree/part/filters.py | 57 +++++++++------- src/backend/InvenTree/part/models.py | 34 ++++++---- src/backend/InvenTree/part/serializers.py | 52 ++++++++++----- src/backend/InvenTree/part/test_api.py | 66 ++++++++++++++++++- 8 files changed, 210 insertions(+), 80 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 55c94fdc04..af05ad088c 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 14c36fa773..18b08565f3 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -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(), ) ) diff --git a/src/backend/InvenTree/company/filters.py b/src/backend/InvenTree/company/filters.py index e9990c0baa..3a89e30605 100644 --- a/src/backend/InvenTree/company/filters.py +++ b/src/backend/InvenTree/company/filters.py @@ -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, diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 86339c39ae..e1bb3dfc1d 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -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(), ) ) diff --git a/src/backend/InvenTree/part/filters.py b/src/backend/InvenTree/part/filters.py index a5be94a6a2..bb57b4815b 100644 --- a/src/backend/InvenTree/part/filters.py +++ b/src/backend/InvenTree/part/filters.py @@ -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(), diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index fbfb38ee95..8b7e0b9988 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -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(), ) ) diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 465d3cfd92..9eeebe1fe2 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -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(), ) ) diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 0c08b334a2..8739510973 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -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."""