mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
57546dd115
@ -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
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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 """
|
||||
|
@ -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
|
||||
"""
|
||||
|
@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user