2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-18 13:05:42 +00:00

Queryset annotation refactor (#3117)

* Refactor out 'ordering' serializer annotation field

* Refactor BomItem serializer annotations

* Factor out MPTT OuterRef query

* Add 'available_stock' annotation to SalesOrderLineItem serializer

- Allows for better rendering of stock availability in sales order table

* Improve 'available quantity' rendering of salesorderlineitem table

* Bump API version

* Add docstring
This commit is contained in:
Oliver
2022-06-02 23:22:47 +10:00
committed by GitHub
parent 2074bf9156
commit 309ed595d7
7 changed files with 237 additions and 142 deletions

141
InvenTree/part/filters.py Normal file
View File

@ -0,0 +1,141 @@
"""Custom query filters for the Part model
The code here makes heavy use of subquery annotations!
Useful References:
- https://hansonkd.medium.com/the-dramatic-benefits-of-django-subqueries-and-annotations-4195e0dafb16
- https://pypi.org/project/django-sql-utils/
- https://docs.djangoproject.com/en/4.0/ref/models/expressions/
- https://stackoverflow.com/questions/42543978/django-1-11-annotating-a-subquery-aggregate
Relevant PRs:
- https://github.com/inventree/InvenTree/pull/2797/
- https://github.com/inventree/InvenTree/pull/2827
"""
from decimal import Decimal
from django.db import models
from django.db.models import OuterRef, Q
from django.db.models.functions import Coalesce
from sql_util.utils import SubquerySum
import stock.models
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
SalesOrderStatus)
def annotate_on_order_quantity(reference: str = ''):
"""Annotate the 'on order' quantity for each part in a queryset"""
# Filter only 'active' purhase orders
order_filter = Q(order__status__in=PurchaseOrderStatus.OPEN)
return Coalesce(
SubquerySum(f'{reference}supplier_parts__purchase_order_line_items__quantity', filter=order_filter),
Decimal(0),
output_field=models.DecimalField()
) - Coalesce(
SubquerySum(f'{reference}supplier_parts__purchase_order_line_items__received', filter=order_filter),
Decimal(0),
output_field=models.DecimalField(),
)
def annotate_total_stock(reference: str = ''):
"""Annotate 'total stock' quantity against a queryset:
- This function calculates the 'total stock' for a given part
- Finds all stock items associated with each part (using the provided filter)
- Aggregates the 'quantity' of each relevent stock item
Args:
reference: The relationship reference of the part from the current model e.g. 'part'
stock_filter: Q object which defines how to filter the stock items
"""
# Stock filter only returns 'in stock' items
stock_filter = stock.models.StockItem.IN_STOCK_FILTER
return Coalesce(
SubquerySum(
f'{reference}stock_items__quantity',
filter=stock_filter,
),
Decimal(0),
output_field=models.DecimalField(),
)
def annotate_build_order_allocations(reference: str = ''):
"""Annotate the total quantity of each part allocated to build orders:
- This function calculates the total part quantity allocated to open build orders
- Finds all build order allocations for each part (using the provided filter)
- Aggregates the 'allocated quantity' for each relevent build order allocation item
Args:
reference: The relationship reference of the part from the current model
build_filter: Q object which defines how to filter the allocation items
"""
# Build filter only returns 'active' build orders
build_filter = Q(build__status__in=BuildStatus.ACTIVE_CODES)
return Coalesce(
SubquerySum(
f'{reference}stock_items__allocations__quantity',
filter=build_filter,
),
Decimal(0),
output_field=models.DecimalField(),
)
def annotate_sales_order_allocations(reference: str = ''):
"""Annotate the total quantity of each part allocated to sales orders:
- This function calculates the total part quantity allocated to open sales orders"
- Finds all sales order allocations for each part (using the provided filter)
- Aggregates the 'allocated quantity' for each relevent sales order allocation item
Args:
reference: The relationship reference of the part from the current model
order_filter: Q object which defines how to filter the allocation items
"""
# Order filter only returns incomplete shipments for open orders
order_filter = Q(
line__order__status__in=SalesOrderStatus.OPEN,
shipment__shipment_date=None,
)
return Coalesce(
SubquerySum(
f'{reference}stock_items__sales_order_allocations__quantity',
filter=order_filter,
),
Decimal(0),
output_field=models.DecimalField(),
)
def variant_stock_query(reference: str = '', filter: Q = stock.models.StockItem.IN_STOCK_FILTER):
"""Create a queryset to retrieve all stock items for variant parts under the specified part
- Useful for annotating a queryset with aggregated information about variant parts
Args:
reference: The relationship reference of the part from the current model
filter: Q object which defines how to filter the returned StockItem instances
"""
return stock.models.StockItem.objects.filter(
part__tree_id=OuterRef(f'{reference}tree_id'),
part__lft__gt=OuterRef(f'{reference}lft'),
part__rght__lt=OuterRef(f'{reference}rght'),
).filter(filter)

View File

@ -4,8 +4,8 @@ import imghdr
from decimal import Decimal
from django.db import models, transaction
from django.db.models import (ExpressionWrapper, F, FloatField, Func, OuterRef,
Q, Subquery)
from django.db.models import (ExpressionWrapper, F, FloatField, Func, Q,
Subquery)
from django.db.models.functions import Coalesce
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
@ -14,6 +14,7 @@ from djmoney.contrib.django_rest_framework import MoneyField
from rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum
import part.filters
from common.settings import currency_code_default, currency_code_mappings
from InvenTree.serializers import (DataFileExtractSerializer,
DataFileUploadSerializer,
@ -23,9 +24,7 @@ from InvenTree.serializers import (DataFileExtractSerializer,
InvenTreeImageSerializerField,
InvenTreeModelSerializer,
InvenTreeMoneySerializer)
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
SalesOrderStatus)
from stock.models import StockItem
from InvenTree.status_codes import BuildStatus
from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
PartCategory, PartCategoryParameterTemplate,
@ -311,14 +310,6 @@ class PartSerializer(InvenTreeModelSerializer):
Performing database queries as efficiently as possible, to reduce database trips.
"""
# Annotate with the total 'in stock' quantity
queryset = queryset.annotate(
in_stock=Coalesce(
SubquerySum('stock_items__quantity', filter=StockItem.IN_STOCK_FILTER),
Decimal(0),
output_field=models.DecimalField(),
),
)
# Annotate with the total number of stock items
queryset = queryset.annotate(
@ -326,11 +317,7 @@ class PartSerializer(InvenTreeModelSerializer):
)
# Annotate with the total variant stock quantity
variant_query = StockItem.objects.filter(
part__tree_id=OuterRef('tree_id'),
part__lft__gt=OuterRef('lft'),
part__rght__lt=OuterRef('rght'),
).filter(StockItem.IN_STOCK_FILTER)
variant_query = part.filters.variant_stock_query()
queryset = queryset.annotate(
variant_stock=Coalesce(
@ -357,24 +344,6 @@ class PartSerializer(InvenTreeModelSerializer):
)
)
# Filter to limit orders to "open"
order_filter = Q(
order__status__in=PurchaseOrderStatus.OPEN
)
# Annotate with the total 'on order' quantity
queryset = queryset.annotate(
ordering=Coalesce(
SubquerySum('supplier_parts__purchase_order_line_items__quantity', filter=order_filter),
Decimal(0),
output_field=models.DecimalField(),
) - Coalesce(
SubquerySum('supplier_parts__purchase_order_line_items__received', filter=order_filter),
Decimal(0),
output_field=models.DecimalField(),
)
)
# Annotate with the number of 'suppliers'
queryset = queryset.annotate(
suppliers=Coalesce(
@ -384,40 +353,11 @@ class PartSerializer(InvenTreeModelSerializer):
),
)
"""
Annotate with the number of stock items allocated to sales orders.
This annotation is modeled on Part.sales_order_allocations() method:
- Only look for "open" orders
- Stock items have not been "shipped"
"""
so_allocation_filter = Q(
line__order__status__in=SalesOrderStatus.OPEN, # LineItem points to an OPEN order
shipment__shipment_date=None, # Allocated item has *not* been shipped out
)
queryset = queryset.annotate(
allocated_to_sales_orders=Coalesce(
SubquerySum('stock_items__sales_order_allocations__quantity', filter=so_allocation_filter),
Decimal(0),
output_field=models.DecimalField(),
)
)
"""
Annotate with the number of stock items allocated to build orders.
This annotation is modeled on Part.build_order_allocations() method
"""
bo_allocation_filter = Q(
build__status__in=BuildStatus.ACTIVE_CODES,
)
queryset = queryset.annotate(
allocated_to_build_orders=Coalesce(
SubquerySum('stock_items__allocations__quantity', filter=bo_allocation_filter),
Decimal(0),
output_field=models.DecimalField(),
)
ordering=part.filters.annotate_on_order_quantity(),
in_stock=part.filters.annotate_total_stock(),
allocated_to_sales_orders=part.filters.annotate_sales_order_allocations(),
allocated_to_build_orders=part.filters.annotate_build_order_allocations(),
)
# Annotate with the total 'available stock' quantity
@ -659,40 +599,16 @@ class BomItemSerializer(InvenTreeModelSerializer):
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,
)
ref = 'sub_part__'
# 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(),
),
total_stock=part.filters.annotate_total_stock(reference=ref),
allocated_to_sales_orders=part.filters.annotate_sales_order_allocations(reference=ref),
allocated_to_build_orders=part.filters.annotate_build_order_allocations(reference=ref),
)
# Calculate 'available_stock' based on previously annotated fields
@ -703,32 +619,13 @@ class BomItemSerializer(InvenTreeModelSerializer):
)
)
ref = 'substitutes__part__'
# 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(),
),
substitute_stock=part.filters.annotate_total_stock(reference=ref),
substitute_build_allocations=part.filters.annotate_build_order_allocations(reference=ref),
substitute_sales_allocations=part.filters.annotate_sales_order_allocations(reference=ref)
)
# Calculate 'available_substitute_stock' field
@ -740,11 +637,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
)
# Annotate the queryset with 'available variant stock' information
variant_stock_query = StockItem.objects.filter(
part__tree_id=OuterRef('sub_part__tree_id'),
part__lft__gt=OuterRef('sub_part__lft'),
part__rght__lt=OuterRef('sub_part__rght'),
).filter(StockItem.IN_STOCK_FILTER)
variant_stock_query = part.filters.variant_stock_query(reference='sub_part__')
queryset = queryset.alias(
variant_stock_total=Coalesce(