Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2022-04-13 21:56:32 +10:00
commit 57546dd115
27 changed files with 11872 additions and 11205 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

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

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',