Merge pull request #2806 from SchrodingersGat/bom-serializer-quantity

Bom serializer quantity
This commit is contained in:
Oliver 2022-04-13 07:43:25 +10:00 committed by GitHub
commit 93257d547c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 236 additions and 29 deletions

View File

@ -12,11 +12,14 @@ import common.models
INVENTREE_SW_VERSION = "0.7.0 dev"
# InvenTree API version
INVENTREE_API_VERSION = 36
INVENTREE_API_VERSION = 37
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v37 -> 2022-04-07 : https://github.com/inventree/InvenTree/pull/2806
- Adds extra stock availability information to the BomItem serializer
v36 -> 2022-04-03
- Adds ability to filter part list endpoint by unallocated_stock argument

View File

@ -1602,9 +1602,10 @@ class BomList(generics.ListCreateAPIView):
def get_queryset(self, *args, **kwargs):
queryset = BomItem.objects.all()
queryset = super().get_queryset(*args, **kwargs)
queryset = self.get_serializer_class().setup_eager_loading(queryset)
queryset = self.get_serializer_class().annotate_queryset(queryset)
return queryset
@ -1818,6 +1819,15 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = BomItem.objects.all()
serializer_class = part_serializers.BomItemSerializer
def get_queryset(self, *args, **kwargs):
queryset = super().get_queryset(*args, **kwargs)
queryset = self.get_serializer_class().setup_eager_loading(queryset)
queryset = self.get_serializer_class().annotate_queryset(queryset)
return queryset
class BomItemValidate(generics.UpdateAPIView):
""" API endpoint for validating a BomItem """

View File

@ -2882,23 +2882,6 @@ class BomItem(models.Model, DataImportMixin):
child=self.sub_part.full_name,
n=decimal2string(self.quantity))
def available_stock(self):
"""
Return the available stock items for the referenced sub_part
"""
query = self.sub_part.stock_items.all()
query = query.prefetch_related([
'sub_part__stock_items',
])
query = query.filter(StockModels.StockItem.IN_STOCK_FILTER).aggregate(
available=Coalesce(Sum('quantity'), 0)
)
return query['available']
def get_overage_quantity(self, quantity):
""" Calculate overage quantity
"""

View File

@ -577,6 +577,10 @@ class BomItemSerializer(InvenTreeModelSerializer):
purchase_price_range = serializers.SerializerMethodField()
# Annotated fields
available_stock = serializers.FloatField(read_only=True)
available_substitute_stock = serializers.FloatField(read_only=True)
def __init__(self, *args, **kwargs):
# part_detail and sub_part_detail serializers are only included if requested.
# This saves a bunch of database requests
@ -609,10 +613,110 @@ class BomItemSerializer(InvenTreeModelSerializer):
queryset = queryset.prefetch_related('sub_part')
queryset = queryset.prefetch_related('sub_part__category')
queryset = queryset.prefetch_related('sub_part__stock_items')
queryset = queryset.prefetch_related(
'sub_part__stock_items',
'sub_part__stock_items__allocations',
'sub_part__stock_items__sales_order_allocations',
)
queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks')
return queryset
@staticmethod
def annotate_queryset(queryset):
"""
Annotate the BomItem queryset with extra information:
Annotations:
available_stock: The amount of stock available for the sub_part Part object
"""
"""
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,
)
# 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(),
),
)
# 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'),
output_field=models.DecimalField(),
)
)
# 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(),
),
)
# Calculate 'available_variant_stock' field
queryset = queryset.annotate(
available_substitute_stock=ExpressionWrapper(
F('substitute_stock') - F('substitute_build_allocations') - F('substitute_sales_allocations'),
output_field=models.DecimalField(),
)
)
return queryset
def get_purchase_price_range(self, obj):
""" Return purchase price range """
@ -682,6 +786,10 @@ class BomItemSerializer(InvenTreeModelSerializer):
'substitutes',
'price_range',
'validated',
# Annotated fields describing available quantity
'available_stock',
'available_substitute_stock',
]

View File

@ -9,7 +9,7 @@ from rest_framework import status
from rest_framework.test import APIClient
from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import BuildStatus, StockStatus
from InvenTree.status_codes import BuildStatus, StockStatus, PurchaseOrderStatus
from part.models import Part, PartCategory
from part.models import BomItem, BomItemSubstitute
@ -578,7 +578,12 @@ class PartDetailTests(InvenTreeAPITestCase):
'part',
'location',
'bom',
'company',
'test_templates',
'manufacturer_part',
'supplier_part',
'order',
'stock',
]
roles = [
@ -805,6 +810,38 @@ class PartDetailTests(InvenTreeAPITestCase):
# And now check that the image has been set
p = Part.objects.get(pk=pk)
def test_details(self):
"""
Test that the required details are available
"""
p = Part.objects.get(pk=1)
url = reverse('api-part-detail', kwargs={'pk': 1})
data = self.get(url, expected_code=200).data
# How many parts are 'on order' for this part?
lines = order.models.PurchaseOrderLineItem.objects.filter(
part__part__pk=1,
order__status__in=PurchaseOrderStatus.OPEN,
)
on_order = 0
# Calculate the "on_order" quantity by hand,
# to check it matches the API value
for line in lines:
on_order += line.quantity
on_order -= line.received
self.assertEqual(on_order, data['ordering'])
self.assertEqual(on_order, p.on_order)
# Some other checks
self.assertEqual(data['in_stock'], 9000)
self.assertEqual(data['unallocated_stock'], 9000)
class PartAPIAggregationTest(InvenTreeAPITestCase):
"""
@ -1123,6 +1160,12 @@ class BomItemTest(InvenTreeAPITestCase):
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['pk'], bom_item.pk)
# Each item in response should contain expected keys
for el in response.data:
for key in ['available_stock', 'available_substitute_stock']:
self.assertTrue(key in el)
def test_get_bom_detail(self):
"""
Get the detail view for a single BomItem object
@ -1132,6 +1175,26 @@ class BomItemTest(InvenTreeAPITestCase):
response = self.get(url, expected_code=200)
expected_values = [
'allow_variants',
'inherited',
'note',
'optional',
'overage',
'pk',
'part',
'quantity',
'reference',
'sub_part',
'substitutes',
'validated',
'available_stock',
'available_substitute_stock',
]
for key in expected_values:
self.assertTrue(key in response.data)
self.assertEqual(int(float(response.data['quantity'])), 25)
# Increase the quantity
@ -1319,6 +1382,21 @@ class BomItemTest(InvenTreeAPITestCase):
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 5)
# The BomItem detail endpoint should now also reflect the substitute data
data = self.get(
reverse('api-bom-item-detail', kwargs={'pk': bom_item.pk}),
expected_code=200
).data
# 5 substitute parts
self.assertEqual(len(data['substitutes']), 5)
# 5 x 1,000 stock quantity
self.assertEqual(data['available_substitute_stock'], 5000)
# 9,000 stock directly available
self.assertEqual(data['available_stock'], 9000)
def test_bom_item_uses(self):
"""
Tests for the 'uses' field

View File

@ -798,17 +798,25 @@ function loadBomTable(table, options={}) {
});
cols.push({
field: 'sub_part_detail.stock',
field: 'available_stock',
title: '{% trans "Available" %}',
searchable: false,
sortable: true,
formatter: function(value, row) {
var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
var text = value;
if (value == null || value <= 0) {
text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock" %}</span>`;
// Calculate total "available" (unallocated) quantity
var total = row.available_stock + row.available_substitute_stock;
var text = `${total}`;
if (total <= 0) {
text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`;
} else {
if (row.available_substitute_stock > 0) {
text += `<span title='{% trans "Includes substitute stock" %}' class='fas fa-info-circle float-right icon-blue'></span>`;
}
}
return renderLink(text, url);
@ -902,8 +910,10 @@ function loadBomTable(table, options={}) {
formatter: function(value, row) {
var can_build = 0;
var available = row.available_stock + row.available_substitute_stock;
if (row.quantity > 0) {
can_build = row.sub_part_detail.stock / row.quantity;
can_build = available / row.quantity;
}
return +can_build.toFixed(2);
@ -914,11 +924,11 @@ function loadBomTable(table, options={}) {
var cb_b = 0;
if (rowA.quantity > 0) {
cb_a = rowA.sub_part_detail.stock / rowA.quantity;
cb_a = (rowA.available_stock + rowA.available_substitute_stock) / rowA.quantity;
}
if (rowB.quantity > 0) {
cb_b = rowB.sub_part_detail.stock / rowB.quantity;
cb_b = (rowB.available_stock + rowB.available_substitute_stock) / rowB.quantity;
}
return (cb_a > cb_b) ? 1 : -1;

View File

@ -1421,9 +1421,24 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
sortable: true,
},
{
field: 'sub_part_detail.stock',
field: 'available_stock',
title: '{% trans "Available" %}',
sortable: true,
formatter: function(value, row) {
var total = row.available_stock + row.available_substitute_stock;
var text = `${total}`;
if (total <= 0) {
text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock Available" %}</span>`;
} else {
if (row.available_substitute_stock > 0) {
text += `<span title='{% trans "Includes substitute stock" %}' class='fas fa-info-circle float-right icon-blue'></span>`;
}
}
return text;
}
},
{
field: 'allocated',