mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-18 21:15:41 +00:00
Merge branch 'inventree:master' into matmair/issue2385
This commit is contained in:
@ -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 """
|
||||
|
@ -1313,19 +1313,31 @@ class Part(MPTTModel):
|
||||
|
||||
return quantity
|
||||
|
||||
def build_order_allocations(self):
|
||||
def build_order_allocations(self, **kwargs):
|
||||
"""
|
||||
Return all 'BuildItem' objects which allocate this part to Build objects
|
||||
"""
|
||||
|
||||
return BuildModels.BuildItem.objects.filter(stock_item__part__id=self.id)
|
||||
include_variants = kwargs.get('include_variants', True)
|
||||
|
||||
def build_order_allocation_count(self):
|
||||
queryset = BuildModels.BuildItem.objects.all()
|
||||
|
||||
if include_variants:
|
||||
variants = self.get_descendants(include_self=True)
|
||||
queryset = queryset.filter(
|
||||
stock_item__part__in=variants,
|
||||
)
|
||||
else:
|
||||
queryset = queryset.filter(stock_item__part=self)
|
||||
|
||||
return queryset
|
||||
|
||||
def build_order_allocation_count(self, **kwargs):
|
||||
"""
|
||||
Return the total amount of this part allocated to build orders
|
||||
"""
|
||||
|
||||
query = self.build_order_allocations().aggregate(
|
||||
query = self.build_order_allocations(**kwargs).aggregate(
|
||||
total=Coalesce(
|
||||
Sum(
|
||||
'quantity',
|
||||
@ -1343,7 +1355,19 @@ class Part(MPTTModel):
|
||||
Return all sales-order-allocation objects which allocate this part to a SalesOrder
|
||||
"""
|
||||
|
||||
queryset = OrderModels.SalesOrderAllocation.objects.filter(item__part__id=self.id)
|
||||
include_variants = kwargs.get('include_variants', True)
|
||||
|
||||
queryset = OrderModels.SalesOrderAllocation.objects.all()
|
||||
|
||||
if include_variants:
|
||||
# Include allocations for all variants
|
||||
variants = self.get_descendants(include_self=True)
|
||||
queryset = queryset.filter(
|
||||
item__part__in=variants,
|
||||
)
|
||||
else:
|
||||
# Only look at this part
|
||||
queryset = queryset.filter(item__part=self)
|
||||
|
||||
# Default behaviour is to only return *pending* allocations
|
||||
pending = kwargs.get('pending', True)
|
||||
@ -1381,7 +1405,7 @@ class Part(MPTTModel):
|
||||
|
||||
return query['total']
|
||||
|
||||
def allocation_count(self):
|
||||
def allocation_count(self, **kwargs):
|
||||
"""
|
||||
Return the total quantity of stock allocated for this part,
|
||||
against both build orders and sales orders.
|
||||
@ -1389,8 +1413,8 @@ class Part(MPTTModel):
|
||||
|
||||
return sum(
|
||||
[
|
||||
self.build_order_allocation_count(),
|
||||
self.sales_order_allocation_count(),
|
||||
self.build_order_allocation_count(**kwargs),
|
||||
self.sales_order_allocation_count(**kwargs),
|
||||
],
|
||||
)
|
||||
|
||||
@ -2882,23 +2906,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',
|
||||
]
|
||||
|
||||
|
||||
|
@ -1,10 +0,0 @@
|
||||
{% load i18n %}
|
||||
|
||||
<div class="markdownx row">
|
||||
<div class="markdown col-md-6">
|
||||
{% include 'django/forms/widgets/textarea.html' %}
|
||||
</div>
|
||||
<div class="markdown col-md-6">
|
||||
<div class="markdownx-preview"></div>
|
||||
</div>
|
||||
</div>
|
@ -187,6 +187,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-stock'>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Stock Items" %}</h4>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% include "stock_table.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='panel panel-hidden' id='panel-parameters'>
|
||||
<div class='panel-heading'>
|
||||
<h4>{% trans "Part Parameters" %}</h4>
|
||||
@ -223,6 +232,21 @@
|
||||
{{ block.super }}
|
||||
|
||||
{% if category %}
|
||||
|
||||
onPanelLoad('stock', function() {
|
||||
loadStockTable(
|
||||
$('#stock-table'),
|
||||
{
|
||||
params: {
|
||||
category: {{ category.pk }},
|
||||
part_detail: true,
|
||||
location_detail: true,
|
||||
supplier_part_detail: true,
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
onPanelLoad('parameters', function() {
|
||||
loadParametricPartTable(
|
||||
"#parametric-part-table",
|
||||
|
@ -14,6 +14,8 @@
|
||||
{% include "sidebar_link.html" with url=url text=text icon="fa-file-upload" %}
|
||||
{% endif %}
|
||||
{% if category %}
|
||||
{% trans "Stock Items" as text %}
|
||||
{% include "sidebar_item.html" with label='stock' text=text icon='fa-boxes' %}
|
||||
{% trans "Parameters" as text %}
|
||||
{% include "sidebar_item.html" with label="parameters" text=text icon="fa-tasks" %}
|
||||
{% endif %}
|
@ -3,7 +3,6 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load markdownify %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include 'part/part_sidebar.html' %}
|
||||
@ -134,24 +133,16 @@
|
||||
|
||||
<div class='panel panel-hidden' id='panel-part-notes'>
|
||||
<div class='panel-heading'>
|
||||
<div class='row'>
|
||||
<div class='col-sm-6'>
|
||||
<h4>{% trans "Notes" %}</h4>
|
||||
</div>
|
||||
<div class='col-sm-6'>
|
||||
<div class='btn-group float-right'>
|
||||
<button type='button' id='edit-notes' title='{% trans "Edit Notes" %}' class='btn btn-outline-secondary'>
|
||||
<span class='fas fa-edit'>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Part Notes" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "notes_buttons.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% if part.notes %}
|
||||
{{ part.notes | markdownify }}
|
||||
{% endif %}
|
||||
<textarea id='part-notes'></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -419,6 +410,18 @@
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
// Load the "notes" tab
|
||||
onPanelLoad('part-notes', function() {
|
||||
|
||||
setupNotesField(
|
||||
'part-notes',
|
||||
'{% url "api-part-detail" part.pk %}',
|
||||
{
|
||||
editable: {% if roles.part.change %}true{% else %}false{% endif %},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Load the "scheduling" tab
|
||||
onPanelLoad('scheduling', function() {
|
||||
loadPartSchedulingChart('part-schedule-chart', {{ part.pk }});
|
||||
@ -832,36 +835,6 @@
|
||||
});
|
||||
});
|
||||
|
||||
$('#edit-notes').click(function() {
|
||||
constructForm('{% url "api-part-detail" part.pk %}', {
|
||||
fields: {
|
||||
notes: {
|
||||
multiline: true,
|
||||
}
|
||||
},
|
||||
title: '{% trans "Edit Part Notes" %}',
|
||||
reload: true,
|
||||
});
|
||||
});
|
||||
|
||||
$(".slidey").change(function() {
|
||||
var field = $(this).attr('fieldname');
|
||||
|
||||
var checked = $(this).prop('checked');
|
||||
|
||||
var data = {};
|
||||
|
||||
data[field] = checked;
|
||||
// Update the particular field
|
||||
inventreePut("{% url 'api-part-detail' part.id %}",
|
||||
data,
|
||||
{
|
||||
method: 'PATCH',
|
||||
reloadOnSuccess: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
onPanelLoad("part-parameters", function() {
|
||||
loadPartParameterTable(
|
||||
'#parameter-table',
|
||||
|
@ -252,7 +252,6 @@
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if not part.is_template %}
|
||||
{% if part.assembly %}
|
||||
<tr>
|
||||
<td><span class='fas fa-tools'></span></td>
|
||||
@ -266,7 +265,6 @@
|
||||
<td>{% decimal quantity_being_built %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endblock details_right %}
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user