diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py
index 423aea9f97..49968047cd 100644
--- a/InvenTree/InvenTree/api_version.py
+++ b/InvenTree/InvenTree/api_version.py
@@ -2,12 +2,16 @@
# InvenTree API version
-INVENTREE_API_VERSION = 53
+INVENTREE_API_VERSION = 54
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
-v52 -> 2022-06-01 : https://github.com/inventree/InvenTree/pull/3110
+v54 -> 2022-06-02 : https://github.com/inventree/InvenTree/pull/3117
+ - Adds 'available_stock' annotation on the SalesOrderLineItem API
+ - Adds (well, fixes) 'overdue' annotation on the SalesOrderLineItem API
+
+v53 -> 2022-06-01 : https://github.com/inventree/InvenTree/pull/3110
- Adds extra search fields to the BuildOrder list API endpoint
v52 -> 2022-05-31 : https://github.com/inventree/InvenTree/pull/3103
diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py
index 2cb3e394ad..7b78033da8 100644
--- a/InvenTree/order/api.py
+++ b/InvenTree/order/api.py
@@ -788,6 +788,8 @@ class SalesOrderLineItemList(generics.ListCreateAPIView):
'order__stock_items',
)
+ queryset = serializers.SalesOrderLineItemSerializer.annotate_queryset(queryset)
+
return queryset
filter_backends = [
@@ -835,6 +837,14 @@ class SalesOrderLineItemDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = models.SalesOrderLineItem.objects.all()
serializer_class = serializers.SalesOrderLineItemSerializer
+ def get_queryset(self, *args, **kwargs):
+ """Return annotated queryset for this endpoint"""
+ queryset = super().get_queryset(*args, **kwargs)
+
+ queryset = serializers.SalesOrderLineItemSerializer.annotate_queryset(queryset)
+
+ return queryset
+
class SalesOrderContextMixin:
"""Mixin to add sales order object as serializer context variable."""
diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py
index bc915fa564..25b4085381 100644
--- a/InvenTree/order/models.py
+++ b/InvenTree/order/models.py
@@ -887,14 +887,6 @@ class OrderLineItem(models.Model):
target_date: An (optional) date for expected shipment of this line item.
"""
- """
- Query filter for determining if an individual line item is "overdue":
- - Amount received is less than the required quantity
- - Target date is not None
- - Target date is in the past
- """
- OVERDUE_FILTER = Q(received__lt=F('quantity')) & ~Q(target_date=None) & Q(target_date__lt=datetime.now().date())
-
class Meta:
"""Metaclass options. Abstract ensures no database table is created."""
@@ -953,6 +945,9 @@ class PurchaseOrderLineItem(OrderLineItem):
order: Reference to a PurchaseOrder object
"""
+ # Filter for determining if a particular PurchaseOrderLineItem is overdue
+ OVERDUE_FILTER = Q(received__lt=F('quantity')) & ~Q(target_date=None) & Q(target_date__lt=datetime.now().date())
+
@staticmethod
def get_api_url():
"""Return the API URL associated with the PurchaseOrderLineItem model"""
@@ -1076,6 +1071,9 @@ class SalesOrderLineItem(OrderLineItem):
shipped: The number of items which have actually shipped against this line item
"""
+ # Filter for determining if a particular SalesOrderLineItem is overdue
+ OVERDUE_FILTER = Q(shipped__lt=F('quantity')) & ~Q(target_date=None) & Q(target_date__lt=datetime.now().date())
+
@staticmethod
def get_api_url():
"""Return the API URL associated with the SalesOrderLineItem model"""
diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py
index 0ebeb95c42..e028c8a31a 100644
--- a/InvenTree/order/serializers.py
+++ b/InvenTree/order/serializers.py
@@ -14,6 +14,7 @@ from rest_framework.serializers import ValidationError
from sql_util.utils import SubqueryCount
import order.models
+import part.filters
import stock.models
import stock.serializers
from common.settings import currency_code_mappings
@@ -248,7 +249,7 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
queryset = queryset.annotate(
overdue=Case(
When(
- Q(order__status__in=PurchaseOrderStatus.OPEN) & order.models.OrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField())
+ Q(order__status__in=PurchaseOrderStatus.OPEN) & order.models.PurchaseOrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField())
),
default=Value(False, output_field=BooleanField()),
)
@@ -790,17 +791,36 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
def annotate_queryset(queryset):
"""Add some extra annotations to this queryset:
- - "Overdue" status (boolean field)
+ - "overdue" status (boolean field)
+ - "available_quantity"
"""
+
queryset = queryset.annotate(
overdue=Case(
When(
- Q(order__status__in=SalesOrderStatus.OPEN) & order.models.OrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
+ Q(order__status__in=SalesOrderStatus.OPEN) & order.models.SalesOrderLineItem.OVERDUE_FILTER, then=Value(True, output_field=BooleanField()),
),
default=Value(False, output_field=BooleanField()),
)
)
+ # Annotate each line with the available stock quantity
+ # To do this, we need to look at the total stock and any allocations
+ queryset = queryset.alias(
+ total_stock=part.filters.annotate_total_stock(reference='part__'),
+ allocated_to_sales_orders=part.filters.annotate_sales_order_allocations(reference='part__'),
+ allocated_to_build_orders=part.filters.annotate_build_order_allocations(reference='part__'),
+ )
+
+ queryset = queryset.annotate(
+ available_stock=ExpressionWrapper(
+ F('total_stock') - F('allocated_to_sales_orders') - F('allocated_to_build_orders'),
+ output_field=models.DecimalField()
+ )
+ )
+
+ return queryset
+
def __init__(self, *args, **kwargs):
"""Initializion routine for the serializer:
@@ -825,7 +845,9 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True)
+ # Annotated fields
overdue = serializers.BooleanField(required=False, read_only=True)
+ available_stock = serializers.FloatField(read_only=True)
quantity = InvenTreeDecimalField()
@@ -853,6 +875,7 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
'pk',
'allocated',
'allocations',
+ 'available_stock',
'quantity',
'reference',
'notes',
diff --git a/InvenTree/part/filters.py b/InvenTree/part/filters.py
new file mode 100644
index 0000000000..2cd3150d96
--- /dev/null
+++ b/InvenTree/part/filters.py
@@ -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)
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index 4094821670..031260563f 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -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(
diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js
index 9b4903b22b..4bc9bcb9cd 100644
--- a/InvenTree/templates/js/translated/order.js
+++ b/InvenTree/templates/js/translated/order.js
@@ -3540,9 +3540,35 @@ function loadSalesOrderLineItemTable(table, options={}) {
columns.push(
{
field: 'stock',
- title: '{% trans "In Stock" %}',
+ title: '{% trans "Available Stock" %}',
formatter: function(value, row) {
- return row.part_detail.stock;
+ var available = row.available_stock;
+ var total = row.part_detail.stock;
+ var required = Math.max(row.quantity - row.allocated - row.shipped, 0);
+
+ var html = '';
+
+ if (total > 0) {
+ var url = `/part/${row.part}/?display=part-stock`;
+
+ var text = available;
+
+ if (total != available) {
+ text += ` / ${total}`;
+ }
+
+ html = renderLink(text, url);
+ } else {
+ html += `{% trans "No Stock Available" %}`;
+ }
+
+ if (available >= required) {
+ html += ``;
+ } else {
+ html += ``;
+ }
+
+ return html;
},
},
);