mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-01 03:00:54 +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