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:
commit
89b8d04ca4
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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',
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
Loading…
x
Reference in New Issue
Block a user