mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #2806 from SchrodingersGat/bom-serializer-quantity
Bom serializer quantity
This commit is contained in:
commit
93257d547c
@ -12,11 +12,14 @@ import common.models
|
|||||||
INVENTREE_SW_VERSION = "0.7.0 dev"
|
INVENTREE_SW_VERSION = "0.7.0 dev"
|
||||||
|
|
||||||
# InvenTree API version
|
# 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
|
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
|
v36 -> 2022-04-03
|
||||||
- Adds ability to filter part list endpoint by unallocated_stock argument
|
- Adds ability to filter part list endpoint by unallocated_stock argument
|
||||||
|
|
||||||
|
@ -1602,9 +1602,10 @@ class BomList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
def get_queryset(self, *args, **kwargs):
|
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().setup_eager_loading(queryset)
|
||||||
|
queryset = self.get_serializer_class().annotate_queryset(queryset)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
@ -1818,6 +1819,15 @@ class BomDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
queryset = BomItem.objects.all()
|
queryset = BomItem.objects.all()
|
||||||
serializer_class = part_serializers.BomItemSerializer
|
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):
|
class BomItemValidate(generics.UpdateAPIView):
|
||||||
""" API endpoint for validating a BomItem """
|
""" API endpoint for validating a BomItem """
|
||||||
|
@ -2882,23 +2882,6 @@ class BomItem(models.Model, DataImportMixin):
|
|||||||
child=self.sub_part.full_name,
|
child=self.sub_part.full_name,
|
||||||
n=decimal2string(self.quantity))
|
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):
|
def get_overage_quantity(self, quantity):
|
||||||
""" Calculate overage quantity
|
""" Calculate overage quantity
|
||||||
"""
|
"""
|
||||||
|
@ -577,6 +577,10 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
purchase_price_range = serializers.SerializerMethodField()
|
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):
|
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.
|
||||||
# This saves a bunch of database requests
|
# 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')
|
||||||
queryset = queryset.prefetch_related('sub_part__category')
|
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')
|
queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks')
|
||||||
return queryset
|
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):
|
def get_purchase_price_range(self, obj):
|
||||||
""" Return purchase price range """
|
""" Return purchase price range """
|
||||||
|
|
||||||
@ -682,6 +786,10 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
'substitutes',
|
'substitutes',
|
||||||
'price_range',
|
'price_range',
|
||||||
'validated',
|
'validated',
|
||||||
|
|
||||||
|
# Annotated fields describing available quantity
|
||||||
|
'available_stock',
|
||||||
|
'available_substitute_stock',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ from rest_framework import status
|
|||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
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 Part, PartCategory
|
||||||
from part.models import BomItem, BomItemSubstitute
|
from part.models import BomItem, BomItemSubstitute
|
||||||
@ -578,7 +578,12 @@ class PartDetailTests(InvenTreeAPITestCase):
|
|||||||
'part',
|
'part',
|
||||||
'location',
|
'location',
|
||||||
'bom',
|
'bom',
|
||||||
|
'company',
|
||||||
'test_templates',
|
'test_templates',
|
||||||
|
'manufacturer_part',
|
||||||
|
'supplier_part',
|
||||||
|
'order',
|
||||||
|
'stock',
|
||||||
]
|
]
|
||||||
|
|
||||||
roles = [
|
roles = [
|
||||||
@ -805,6 +810,38 @@ class PartDetailTests(InvenTreeAPITestCase):
|
|||||||
# And now check that the image has been set
|
# And now check that the image has been set
|
||||||
p = Part.objects.get(pk=pk)
|
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):
|
class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||||
"""
|
"""
|
||||||
@ -1123,6 +1160,12 @@ class BomItemTest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(len(response.data), 1)
|
self.assertEqual(len(response.data), 1)
|
||||||
self.assertEqual(response.data[0]['pk'], bom_item.pk)
|
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):
|
def test_get_bom_detail(self):
|
||||||
"""
|
"""
|
||||||
Get the detail view for a single BomItem object
|
Get the detail view for a single BomItem object
|
||||||
@ -1132,6 +1175,26 @@ class BomItemTest(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
response = self.get(url, expected_code=200)
|
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)
|
self.assertEqual(int(float(response.data['quantity'])), 25)
|
||||||
|
|
||||||
# Increase the quantity
|
# Increase the quantity
|
||||||
@ -1319,6 +1382,21 @@ class BomItemTest(InvenTreeAPITestCase):
|
|||||||
response = self.get(url, expected_code=200)
|
response = self.get(url, expected_code=200)
|
||||||
self.assertEqual(len(response.data), 5)
|
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):
|
def test_bom_item_uses(self):
|
||||||
"""
|
"""
|
||||||
Tests for the 'uses' field
|
Tests for the 'uses' field
|
||||||
|
@ -798,17 +798,25 @@ function loadBomTable(table, options={}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
cols.push({
|
cols.push({
|
||||||
field: 'sub_part_detail.stock',
|
field: 'available_stock',
|
||||||
title: '{% trans "Available" %}',
|
title: '{% trans "Available" %}',
|
||||||
searchable: false,
|
searchable: false,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
formatter: function(value, row) {
|
formatter: function(value, row) {
|
||||||
|
|
||||||
var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
|
var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
|
||||||
var text = value;
|
|
||||||
|
|
||||||
if (value == null || value <= 0) {
|
// Calculate total "available" (unallocated) quantity
|
||||||
text = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock" %}</span>`;
|
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);
|
return renderLink(text, url);
|
||||||
@ -902,8 +910,10 @@ 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;
|
||||||
|
|
||||||
if (row.quantity > 0) {
|
if (row.quantity > 0) {
|
||||||
can_build = row.sub_part_detail.stock / row.quantity;
|
can_build = available / row.quantity;
|
||||||
}
|
}
|
||||||
|
|
||||||
return +can_build.toFixed(2);
|
return +can_build.toFixed(2);
|
||||||
@ -914,11 +924,11 @@ function loadBomTable(table, options={}) {
|
|||||||
var cb_b = 0;
|
var cb_b = 0;
|
||||||
|
|
||||||
if (rowA.quantity > 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) {
|
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;
|
return (cb_a > cb_b) ? 1 : -1;
|
||||||
|
@ -1421,9 +1421,24 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'sub_part_detail.stock',
|
field: 'available_stock',
|
||||||
title: '{% trans "Available" %}',
|
title: '{% trans "Available" %}',
|
||||||
sortable: true,
|
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',
|
field: 'allocated',
|
||||||
|
Loading…
Reference in New Issue
Block a user