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

Merge pull request #2833 from SchrodingersGat/variant-available

Add 'available_variant_stock' to BomItem serializer
This commit is contained in:
Oliver 2022-04-26 18:47:56 +10:00 committed by GitHub
commit 89b8d04ca4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 264 additions and 21 deletions

View File

@ -4,11 +4,14 @@ InvenTree API version information
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 41 INVENTREE_API_VERSION = 42
""" """
Increment this API version number whenever there is a significant change to the API that any clients need to know about Increment this API version number whenever there is a significant change to the API that any clients need to know about
v42 -> 2022-04-26 : https://github.com/inventree/InvenTree/pull/2833
- Adds variant stock information to the Part and BomItem serializers
v41 -> 2022-04-26 v41 -> 2022-04-26
- Fixes 'variant_of' filter for Part list endpoint - Fixes 'variant_of' filter for Part list endpoint

View File

@ -7,6 +7,7 @@
part: 100 part: 100
sub_part: 1 sub_part: 1
quantity: 10 quantity: 10
allow_variants: True
# 40 x R_2K2_0805 # 40 x R_2K2_0805
- model: part.bomitem - model: part.bomitem

View File

@ -7,7 +7,9 @@ from decimal import Decimal
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.db import models, transaction from django.db import models, transaction
from django.db.models import ExpressionWrapper, F, Q from django.db.models import ExpressionWrapper, F, Q, Func
from django.db.models import Subquery, OuterRef, FloatField
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -308,9 +310,6 @@ class PartSerializer(InvenTreeModelSerializer):
to reduce database trips. to reduce database trips.
""" """
# TODO: Update the "in_stock" annotation to include stock for variants of the part
# Ref: https://github.com/inventree/InvenTree/issues/2240
# Annotate with the total 'in stock' quantity # Annotate with the total 'in stock' quantity
queryset = queryset.annotate( queryset = queryset.annotate(
in_stock=Coalesce( in_stock=Coalesce(
@ -325,6 +324,24 @@ class PartSerializer(InvenTreeModelSerializer):
stock_item_count=SubqueryCount('stock_items') stock_item_count=SubqueryCount('stock_items')
) )
# 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)
queryset = queryset.annotate(
variant_stock=Coalesce(
Subquery(
variant_query.annotate(
total=Func(F('quantity'), function='SUM', output_field=FloatField())
).values('total')),
0,
output_field=FloatField(),
)
)
# Filter to limit builds to "active" # Filter to limit builds to "active"
build_filter = Q( build_filter = Q(
status__in=BuildStatus.ACTIVE_CODES status__in=BuildStatus.ACTIVE_CODES
@ -429,6 +446,7 @@ class PartSerializer(InvenTreeModelSerializer):
unallocated_stock = serializers.FloatField(read_only=True) unallocated_stock = serializers.FloatField(read_only=True)
building = serializers.FloatField(read_only=True) building = serializers.FloatField(read_only=True)
in_stock = serializers.FloatField(read_only=True) in_stock = serializers.FloatField(read_only=True)
variant_stock = serializers.FloatField(read_only=True)
ordering = serializers.FloatField(read_only=True) ordering = serializers.FloatField(read_only=True)
stock_item_count = serializers.IntegerField(read_only=True) stock_item_count = serializers.IntegerField(read_only=True)
suppliers = serializers.IntegerField(read_only=True) suppliers = serializers.IntegerField(read_only=True)
@ -463,6 +481,7 @@ class PartSerializer(InvenTreeModelSerializer):
'full_name', 'full_name',
'image', 'image',
'in_stock', 'in_stock',
'variant_stock',
'ordering', 'ordering',
'building', 'building',
'IPN', 'IPN',
@ -577,9 +596,10 @@ class BomItemSerializer(InvenTreeModelSerializer):
purchase_price_range = serializers.SerializerMethodField() purchase_price_range = serializers.SerializerMethodField()
# Annotated fields # Annotated fields for available stock
available_stock = serializers.FloatField(read_only=True) available_stock = serializers.FloatField(read_only=True)
available_substitute_stock = serializers.FloatField(read_only=True) available_substitute_stock = serializers.FloatField(read_only=True)
available_variant_stock = serializers.FloatField(read_only=True)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# part_detail and sub_part_detail serializers are only included if requested. # part_detail and sub_part_detail serializers are only included if requested.
@ -613,11 +633,18 @@ class BomItemSerializer(InvenTreeModelSerializer):
queryset = queryset.prefetch_related('sub_part') queryset = queryset.prefetch_related('sub_part')
queryset = queryset.prefetch_related('sub_part__category') queryset = queryset.prefetch_related('sub_part__category')
queryset = queryset.prefetch_related( queryset = queryset.prefetch_related(
'sub_part__stock_items', 'sub_part__stock_items',
'sub_part__stock_items__allocations', 'sub_part__stock_items__allocations',
'sub_part__stock_items__sales_order_allocations', 'sub_part__stock_items__sales_order_allocations',
) )
queryset = queryset.prefetch_related(
'substitutes',
'substitutes__part__stock_items',
)
queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks') queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks')
return queryset return queryset
@ -707,7 +734,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
), ),
) )
# Calculate 'available_variant_stock' field # Calculate 'available_substitute_stock' field
queryset = queryset.annotate( queryset = queryset.annotate(
available_substitute_stock=ExpressionWrapper( available_substitute_stock=ExpressionWrapper(
F('substitute_stock') - F('substitute_build_allocations') - F('substitute_sales_allocations'), F('substitute_stock') - F('substitute_build_allocations') - F('substitute_sales_allocations'),
@ -715,6 +742,47 @@ 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)
queryset = queryset.alias(
variant_stock_total=Coalesce(
Subquery(
variant_stock_query.annotate(
total=Func(F('quantity'), function='SUM', output_field=FloatField())
).values('total')),
0,
output_field=FloatField()
),
variant_stock_build_order_allocations=Coalesce(
Subquery(
variant_stock_query.annotate(
total=Func(F('sales_order_allocations__quantity'), function='SUM', output_field=FloatField()),
).values('total')),
0,
output_field=FloatField(),
),
variant_stock_sales_order_allocations=Coalesce(
Subquery(
variant_stock_query.annotate(
total=Func(F('allocations__quantity'), function='SUM', output_field=FloatField()),
).values('total')),
0,
output_field=FloatField(),
)
)
queryset = queryset.annotate(
available_variant_stock=ExpressionWrapper(
F('variant_stock_total') - F('variant_stock_build_order_allocations') - F('variant_stock_sales_order_allocations'),
output_field=FloatField(),
)
)
return queryset return queryset
def get_purchase_price_range(self, obj): def get_purchase_price_range(self, obj):
@ -790,6 +858,8 @@ class BomItemSerializer(InvenTreeModelSerializer):
# Annotated fields describing available quantity # Annotated fields describing available quantity
'available_stock', 'available_stock',
'available_substitute_stock', 'available_substitute_stock',
'available_variant_stock',
] ]

View File

@ -658,6 +658,94 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertEqual(len(response.data), 101) self.assertEqual(len(response.data), 101)
def test_variant_stock(self):
"""
Unit tests for the 'variant_stock' annotation,
which provides a stock count for *variant* parts
"""
# Ensure the MPTT structure is in a known state before running tests
Part.objects.rebuild()
# Initially, there are no "chairs" in stock,
# so each 'chair' template should report variant_stock=0
url = reverse('api-part-list')
# Look at the "detail" URL for the master chair template
response = self.get('/api/part/10000/', {}, expected_code=200)
# This part should report 'zero' as variant stock
self.assertEqual(response.data['variant_stock'], 0)
# Grab a list of all variant chairs *under* the master template
response = self.get(
url,
{
'ancestor': 10000,
},
expected_code=200,
)
# 4 total descendants
self.assertEqual(len(response.data), 4)
for variant in response.data:
self.assertEqual(variant['variant_stock'], 0)
# Now, let's make some variant stock
for variant in Part.objects.get(pk=10000).get_descendants(include_self=False):
StockItem.objects.create(
part=variant,
quantity=100,
)
response = self.get('/api/part/10000/', {}, expected_code=200)
self.assertEqual(response.data['in_stock'], 0)
self.assertEqual(response.data['variant_stock'], 400)
# Check that each variant reports the correct stock quantities
response = self.get(
url,
{
'ancestor': 10000,
},
expected_code=200,
)
expected_variant_stock = {
10001: 0,
10002: 0,
10003: 100,
10004: 0,
}
for variant in response.data:
self.assertEqual(variant['in_stock'], 100)
self.assertEqual(variant['variant_stock'], expected_variant_stock[variant['pk']])
# Add some 'sub variants' for the green chair variant
green_chair = Part.objects.get(pk=10004)
for i in range(10):
gcv = Part.objects.create(
name=f"GC Var {i}",
description="Green chair variant",
variant_of=green_chair,
)
StockItem.objects.create(
part=gcv,
quantity=50,
)
# Spot check of some values
response = self.get('/api/part/10000/', {})
self.assertEqual(response.data['variant_stock'], 900)
response = self.get('/api/part/10004/', {})
self.assertEqual(response.data['variant_stock'], 500)
class PartDetailTests(InvenTreeAPITestCase): class PartDetailTests(InvenTreeAPITestCase):
""" """
@ -1541,6 +1629,44 @@ class BomItemTest(InvenTreeAPITestCase):
self.assertEqual(len(response.data), i) self.assertEqual(len(response.data), i)
def test_bom_variant_stock(self):
"""
Test for 'available_variant_stock' annotation
"""
Part.objects.rebuild()
# BOM item we are interested in
bom_item = BomItem.objects.get(pk=1)
response = self.get('/api/bom/1/', {}, expected_code=200)
# Initially, no variant stock available
self.assertEqual(response.data['available_variant_stock'], 0)
# Create some 'variants' of the referenced sub_part
bom_item.sub_part.is_template = True
bom_item.sub_part.save()
for i in range(10):
# Create a variant part
vp = Part.objects.create(
name=f"Var {i}",
description="Variant part",
variant_of=bom_item.sub_part,
)
# Create a stock item
StockItem.objects.create(
part=vp,
quantity=100,
)
# There should now be variant stock available
response = self.get('/api/bom/1/', {}, expected_code=200)
self.assertEqual(response.data['available_variant_stock'], 1000)
class PartParameterTest(InvenTreeAPITestCase): class PartParameterTest(InvenTreeAPITestCase):
""" """

View File

@ -807,15 +807,28 @@ function loadBomTable(table, options={}) {
var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`; var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
// Calculate total "available" (unallocated) quantity // Calculate total "available" (unallocated) quantity
var total = row.available_stock + row.available_substitute_stock; var base_stock = row.available_stock;
var substitute_stock = row.available_substitute_stock || 0;
var variant_stock = row.allow_variants ? (row.available_variant_stock || 0) : 0;
var text = `${total}`; var available_stock = base_stock + substitute_stock + variant_stock;
if (total <= 0) { var text = `${available_stock}`;
if (available_stock <= 0) {
text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`; text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`;
} else { } else {
if (row.available_substitute_stock > 0) { var extra = '';
text += `<span title='{% trans "Includes substitute stock" %}' class='fas fa-info-circle float-right icon-blue'></span>`; if ((substitute_stock > 0) && (variant_stock > 0)) {
extra = '{% trans "Includes variant and substitute stock" %}';
} else if (variant_stock > 0) {
extra = '{% trans "Includes variant stock" %}';
} else if (substitute_stock > 0) {
extra = '{% trans "Includes substitute stock" %}';
}
if (extra) {
text += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
} }
} }
@ -910,7 +923,7 @@ function loadBomTable(table, options={}) {
formatter: function(value, row) { formatter: function(value, row) {
var can_build = 0; var can_build = 0;
var available = row.available_stock + row.available_substitute_stock; var available = row.available_stock + (row.available_substitute_stock || 0) + (row.available_variant_stock || 0);
if (row.quantity > 0) { if (row.quantity > 0) {
can_build = available / row.quantity; can_build = available / row.quantity;

View File

@ -1425,19 +1425,36 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
title: '{% trans "Available" %}', title: '{% trans "Available" %}',
sortable: true, sortable: true,
formatter: function(value, row) { formatter: function(value, row) {
var total = row.available_stock + row.available_substitute_stock;
var text = `${total}`; var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
if (total <= 0) { // Calculate total "available" (unallocated) quantity
var base_stock = row.available_stock;
var substitute_stock = row.available_substitute_stock || 0;
var variant_stock = row.allow_variants ? (row.available_variant_stock || 0) : 0;
var available_stock = base_stock + substitute_stock + variant_stock;
var text = `${available_stock}`;
if (available_stock <= 0) {
text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`; text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`;
} else { } else {
if (row.available_substitute_stock > 0) { var extra = '';
text += `<span title='{% trans "Includes substitute stock" %}' class='fas fa-info-circle float-right icon-blue'></span>`; if ((substitute_stock > 0) && (variant_stock > 0)) {
extra = '{% trans "Includes variant and substitute stock" %}';
} else if (variant_stock > 0) {
extra = '{% trans "Includes variant stock" %}';
} else if (substitute_stock > 0) {
extra = '{% trans "Includes substitute stock" %}';
}
if (extra) {
text += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
} }
} }
return text; return renderLink(text, url);
} }
}, },
{ {

View File

@ -672,7 +672,20 @@ function loadPartVariantTable(table, partId, options={}) {
field: 'in_stock', field: 'in_stock',
title: '{% trans "Stock" %}', title: '{% trans "Stock" %}',
formatter: function(value, row) { formatter: function(value, row) {
return renderLink(value, `/part/${row.pk}/?display=part-stock`);
var base_stock = row.in_stock;
var variant_stock = row.variant_stock || 0;
var total = base_stock + variant_stock;
var text = `${total}`;
if (variant_stock > 0) {
text = `<em>${text}</em>`;
text += `<span title='{% trans "Includes variant stock" %}' class='fas fa-info-circle float-right icon-blue'></span>`;
}
return renderLink(text, `/part/${row.pk}/?display=part-stock`);
} }
} }
]; ];