2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-05 06:48:48 +00:00

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_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

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): 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 """

View File

@ -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
""" """

View File

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

View File

@ -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

View File

@ -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;

View File

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