diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py
index 692ec09480..48dc8df775 100644
--- a/InvenTree/InvenTree/api_version.py
+++ b/InvenTree/InvenTree/api_version.py
@@ -1,11 +1,17 @@
"""InvenTree API version information."""
# InvenTree API version
-INVENTREE_API_VERSION = 177
+INVENTREE_API_VERSION = 178
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
+v178 - 2024-02-29 : https://github.com/inventree/InvenTree/pull/6604
+ - Adds "external_stock" field to the Part API endpoint
+ - Adds "external_stock" field to the BomItem API endpoint
+ - Adds "external_stock" field to the BuildLine API endpoint
+ - Stock quantites represented in the BuildLine API endpoint are now filtered by Build.source_location
+
v177 - 2024-02-27 : https://github.com/inventree/InvenTree/pull/6581
- Adds "subcategoies" count to PartCategoryTree serializer
- Adds "sublocations" count to StockLocationTree serializer
diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py
index 47fd8ae0cf..291e0694ae 100644
--- a/InvenTree/build/api.py
+++ b/InvenTree/build/api.py
@@ -314,11 +314,21 @@ class BuildLineEndpoint:
queryset = BuildLine.objects.all()
serializer_class = build.serializers.BuildLineSerializer
+ def get_source_build(self) -> Build:
+ """Return the source Build object for the BuildLine queryset.
+
+ This source build is used to filter the available stock for each BuildLine.
+
+ - If this is a "detail" view, use the build associated with the line
+ - If this is a "list" view, use the build associated with the request
+ """
+ raise NotImplementedError("get_source_build must be implemented in the child class")
+
def get_queryset(self):
"""Override queryset to select-related and annotate"""
queryset = super().get_queryset()
-
- queryset = build.serializers.BuildLineSerializer.annotate_queryset(queryset)
+ source_build = self.get_source_build()
+ queryset = build.serializers.BuildLineSerializer.annotate_queryset(queryset, build=source_build)
return queryset
@@ -353,10 +363,26 @@ class BuildLineList(BuildLineEndpoint, ListCreateAPI):
'bom_item__reference',
]
+ def get_source_build(self) -> Build:
+ """Return the target build for the BuildLine queryset."""
+
+ try:
+ build_id = self.request.query_params.get('build', None)
+ if build_id:
+ build = Build.objects.get(pk=build_id)
+ return build
+ except (Build.DoesNotExist, AttributeError, ValueError):
+ pass
+
+ return None
class BuildLineDetail(BuildLineEndpoint, RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a BuildLine object."""
- pass
+
+ def get_source_build(self) -> Build:
+ """Return the target source location for the BuildLine queryset."""
+
+ return None
class BuildOrderContextMixin:
diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py
index 41299f04b1..0ae06ef18f 100644
--- a/InvenTree/build/serializers.py
+++ b/InvenTree/build/serializers.py
@@ -1083,6 +1083,7 @@ class BuildLineSerializer(InvenTreeModelSerializer):
'available_substitute_stock',
'available_variant_stock',
'total_available_stock',
+ 'external_stock',
]
read_only_fields = [
@@ -1124,15 +1125,23 @@ class BuildLineSerializer(InvenTreeModelSerializer):
available_substitute_stock = serializers.FloatField(read_only=True)
available_variant_stock = serializers.FloatField(read_only=True)
total_available_stock = serializers.FloatField(read_only=True)
+ external_stock = serializers.FloatField(read_only=True)
@staticmethod
- def annotate_queryset(queryset):
+ def annotate_queryset(queryset, build=None):
"""Add extra annotations to the queryset:
- allocated: Total stock quantity allocated against this build line
- available: Total stock available for allocation against this build line
- on_order: Total stock on order for this build line
- in_production: Total stock currently in production for this build line
+
+ Arguments:
+ queryset: The queryset to annotate
+ build: The build order to filter against (optional)
+
+ Note: If the 'build' is provided, we can use it to filter available stock, depending on the specified location for the build
+
"""
queryset = queryset.select_related(
'build', 'bom_item',
@@ -1169,6 +1178,18 @@ class BuildLineSerializer(InvenTreeModelSerializer):
ref = 'bom_item__sub_part__'
+ stock_filter = None
+
+ if build is not None and build.take_from is not None:
+ location = build.take_from
+ # Filter by locations below the specified location
+ stock_filter = Q(
+ location__tree_id=location.tree_id,
+ location__lft__gte=location.lft,
+ location__rght__lte=location.rght,
+ location__level__gte=location.level,
+ )
+
# Annotate the "in_production" quantity
queryset = queryset.annotate(
in_production=part.filters.annotate_in_production_quantity(reference=ref)
@@ -1181,10 +1202,8 @@ class BuildLineSerializer(InvenTreeModelSerializer):
)
# Annotate the "available" quantity
- # TODO: In the future, this should be refactored.
- # TODO: Note that part.serializers.BomItemSerializer also has a similar annotation
queryset = queryset.alias(
- total_stock=part.filters.annotate_total_stock(reference=ref),
+ total_stock=part.filters.annotate_total_stock(reference=ref, filter=stock_filter),
allocated_to_sales_orders=part.filters.annotate_sales_order_allocations(reference=ref),
allocated_to_build_orders=part.filters.annotate_build_order_allocations(reference=ref),
)
@@ -1197,11 +1216,21 @@ class BuildLineSerializer(InvenTreeModelSerializer):
)
)
+ external_stock_filter = Q(location__external=True)
+
+ if stock_filter:
+ external_stock_filter &= stock_filter
+
+ # Add 'external stock' annotations
+ queryset = queryset.annotate(
+ external_stock=part.filters.annotate_total_stock(reference=ref, filter=external_stock_filter)
+ )
+
ref = 'bom_item__substitutes__part__'
# Extract similar information for any 'substitute' parts
queryset = queryset.alias(
- substitute_stock=part.filters.annotate_total_stock(reference=ref),
+ substitute_stock=part.filters.annotate_total_stock(reference=ref, filter=stock_filter),
substitute_build_allocations=part.filters.annotate_build_order_allocations(reference=ref),
substitute_sales_allocations=part.filters.annotate_sales_order_allocations(reference=ref)
)
@@ -1215,7 +1244,7 @@ class BuildLineSerializer(InvenTreeModelSerializer):
)
# Annotate the queryset with 'available variant stock' information
- variant_stock_query = part.filters.variant_stock_query(reference='bom_item__sub_part__')
+ variant_stock_query = part.filters.variant_stock_query(reference='bom_item__sub_part__', filter=stock_filter)
queryset = queryset.alias(
variant_stock_total=part.filters.annotate_variant_quantity(variant_stock_query, reference='quantity'),
diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html
index 19438e70f5..3d2e5d4e96 100644
--- a/InvenTree/build/templates/build/detail.html
+++ b/InvenTree/build/templates/build/detail.html
@@ -200,6 +200,11 @@
{% include "filter_list.html" with id='buildlines' %}
+ {% if build.take_from %}
+
+ {% trans "Available stock has been filtered based on specified source location for this build order" %}
+
+ {% endif %}
@@ -374,6 +379,9 @@ onPanelLoad('allocate', function() {
"#build-lines-table",
{{ build.pk }},
{
+ {% if build.take_from %}
+ location: {{ build.take_from.pk }},
+ {% endif %}
{% if build.project_code %}
project_code: {{ build.project_code.pk }},
{% endif %}
diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 68d606a551..7b7ec1c7e5 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -1767,6 +1767,7 @@ class BomFilter(rest_filters.FilterSet):
part_active = rest_filters.BooleanFilter(
label='Master part is active', field_name='part__active'
)
+
part_trackable = rest_filters.BooleanFilter(
label='Master part is trackable', field_name='part__trackable'
)
@@ -1775,6 +1776,7 @@ class BomFilter(rest_filters.FilterSet):
sub_part_trackable = rest_filters.BooleanFilter(
label='Sub part is trackable', field_name='sub_part__trackable'
)
+
sub_part_assembly = rest_filters.BooleanFilter(
label='Sub part is an assembly', field_name='sub_part__assembly'
)
@@ -1814,6 +1816,22 @@ class BomFilter(rest_filters.FilterSet):
return queryset.filter(q_a | q_b).distinct()
+ part = rest_filters.ModelChoiceFilter(
+ queryset=Part.objects.all(), method='filter_part', label=_('Part')
+ )
+
+ def filter_part(self, queryset, name, part):
+ """Filter the queryset based on the specified part."""
+ return queryset.filter(part.get_bom_item_filter())
+
+ uses = rest_filters.ModelChoiceFilter(
+ queryset=Part.objects.all(), method='filter_uses', label=_('Uses')
+ )
+
+ def filter_uses(self, queryset, name, part):
+ """Filter the queryset based on the specified part."""
+ return queryset.filter(part.get_used_in_bom_item_filter())
+
class BomMixin:
"""Mixin class for BomItem API endpoints."""
@@ -1889,62 +1907,6 @@ class BomList(BomMixin, ListCreateDestroyAPIView):
return JsonResponse(data, safe=False)
return Response(data)
- def filter_queryset(self, queryset):
- """Custom query filtering for the BomItem list API."""
- queryset = super().filter_queryset(queryset)
-
- params = self.request.query_params
-
- # Filter by part?
- part = params.get('part', None)
-
- if part is not None:
- """
- If we are filtering by "part", there are two cases to consider:
-
- a) Bom items which are defined for *this* part
- b) Inherited parts which are defined for a *parent* part
-
- So we need to construct two queries!
- """
-
- # First, check that the part is actually valid!
- try:
- part = Part.objects.get(pk=part)
-
- queryset = queryset.filter(part.get_bom_item_filter())
-
- except (ValueError, Part.DoesNotExist):
- pass
-
- """
- Filter by 'uses'?
-
- Here we pass a part ID and return BOM items for any assemblies which "use" (or "require") that part.
-
- There are multiple ways that an assembly can "use" a sub-part:
-
- A) Directly specifying the sub_part in a BomItem field
- B) Specifying a "template" part with inherited=True
- C) Allowing variant parts to be substituted
- D) Allowing direct substitute parts to be specified
-
- - BOM items which are "inherited" by parts which are variants of the master BomItem
- """
- uses = params.get('uses', None)
-
- if uses is not None:
- try:
- # Extract the part we are interested in
- uses_part = Part.objects.get(pk=uses)
-
- queryset = queryset.filter(uses_part.get_used_in_bom_item_filter())
-
- except (ValueError, Part.DoesNotExist):
- pass
-
- return queryset
-
filter_backends = SEARCH_ORDER_FILTER_ALIAS
search_fields = [
diff --git a/InvenTree/part/filters.py b/InvenTree/part/filters.py
index f7303cc190..42a41eb923 100644
--- a/InvenTree/part/filters.py
+++ b/InvenTree/part/filters.py
@@ -107,7 +107,7 @@ def annotate_on_order_quantity(reference: str = ''):
)
-def annotate_total_stock(reference: str = ''):
+def annotate_total_stock(reference: str = '', filter: Q = None):
"""Annotate 'total stock' quantity against a queryset.
- This function calculates the 'total stock' for a given part
@@ -121,6 +121,9 @@ def annotate_total_stock(reference: str = ''):
# Stock filter only returns 'in stock' items
stock_filter = stock.models.StockItem.IN_STOCK_FILTER
+ if filter is not None:
+ stock_filter &= filter
+
return Coalesce(
SubquerySum(f'{reference}stock_items__quantity', filter=stock_filter),
Decimal(0),
@@ -216,9 +219,7 @@ def annotate_sales_order_allocations(reference: str = ''):
)
-def variant_stock_query(
- reference: str = '', filter: Q = stock.models.StockItem.IN_STOCK_FILTER
-):
+def variant_stock_query(reference: str = '', filter: Q = None):
"""Create a queryset to retrieve all stock items for variant parts under the specified part.
- Useful for annotating a queryset with aggregated information about variant parts
@@ -227,11 +228,16 @@ def variant_stock_query(
reference: The relationship reference of the part from the current model
filter: Q object which defines how to filter the returned StockItem instances
"""
+ stock_filter = stock.models.StockItem.IN_STOCK_FILTER
+
+ if filter:
+ stock_filter &= filter
+
return stock.models.StockItem.objects.filter(
part__tree_id=OuterRef(f'{reference}tree_id'),
part__lft__gt=OuterRef(f'{reference}lft'),
part__rght__lt=OuterRef(f'{reference}rght'),
- ).filter(filter)
+ ).filter(stock_filter)
def annotate_variant_quantity(subquery: Q, reference: str = 'quantity'):
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index 175c1b5036..c09504853c 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -610,6 +610,7 @@ class PartSerializer(
'stock_item_count',
'suppliers',
'total_in_stock',
+ 'external_stock',
'unallocated_stock',
'variant_stock',
# Fields only used for Part creation
@@ -734,6 +735,12 @@ class PartSerializer(
)
)
+ queryset = queryset.annotate(
+ external_stock=part.filters.annotate_total_stock(
+ filter=Q(location__external=True)
+ )
+ )
+
# Annotate with the total 'available stock' quantity
# This is the current stock, minus any allocations
queryset = queryset.annotate(
@@ -780,14 +787,17 @@ class PartSerializer(
allocated_to_sales_orders = serializers.FloatField(read_only=True)
building = serializers.FloatField(read_only=True)
in_stock = serializers.FloatField(read_only=True)
- ordering = serializers.FloatField(read_only=True)
+ ordering = serializers.FloatField(read_only=True, label=_('On Order'))
required_for_build_orders = serializers.IntegerField(read_only=True)
required_for_sales_orders = serializers.IntegerField(read_only=True)
- stock_item_count = serializers.IntegerField(read_only=True)
- suppliers = serializers.IntegerField(read_only=True)
- total_in_stock = serializers.FloatField(read_only=True)
- unallocated_stock = serializers.FloatField(read_only=True)
- variant_stock = serializers.FloatField(read_only=True)
+ stock_item_count = serializers.IntegerField(read_only=True, label=_('Stock Items'))
+ suppliers = serializers.IntegerField(read_only=True, label=_('Suppliers'))
+ total_in_stock = serializers.FloatField(read_only=True, label=_('Total Stock'))
+ external_stock = serializers.FloatField(read_only=True, label=_('External Stock'))
+ unallocated_stock = serializers.FloatField(
+ read_only=True, label=_('Unallocated Stock')
+ )
+ variant_stock = serializers.FloatField(read_only=True, label=_('Variant Stock'))
minimum_stock = serializers.FloatField()
@@ -1387,6 +1397,7 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'available_stock',
'available_substitute_stock',
'available_variant_stock',
+ 'external_stock',
# Annotated field describing quantity on order
'on_order',
# Annotated field describing quantity being built
@@ -1456,6 +1467,8 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
available_substitute_stock = serializers.FloatField(read_only=True)
available_variant_stock = serializers.FloatField(read_only=True)
+ external_stock = serializers.FloatField(read_only=True)
+
@staticmethod
def setup_eager_loading(queryset):
"""Prefetch against the provided queryset to speed up database access."""
@@ -1534,6 +1547,13 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
)
)
+ # Calculate 'external_stock'
+ queryset = queryset.annotate(
+ external_stock=part.filters.annotate_total_stock(
+ reference=ref, filter=Q(location__external=True)
+ )
+ )
+
ref = 'substitutes__part__'
# Extract similar information for any 'substitute' parts
diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js
index ebf1caec67..11d03c095e 100644
--- a/InvenTree/templates/js/translated/bom.js
+++ b/InvenTree/templates/js/translated/bom.js
@@ -1172,12 +1172,18 @@ function loadBomTable(table, options={}) {
var available_stock = availableQuantity(row);
+ var external_stock = row.external_stock ?? 0;
+
var text = renderLink(`${available_stock}`, url);
if (row.sub_part_detail && row.sub_part_detail.units) {
text += ` ${row.sub_part_detail.units}`;
}
+ if (external_stock > 0) {
+ text += makeIconBadge('fa-sitemap', `{% trans "External stock" %}: ${external_stock}`);
+ }
+
if (available_stock <= 0) {
text += makeIconBadge('fa-times-circle icon-red', '{% trans "No Stock Available" %}');
} else {
diff --git a/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js
index 97b9ba3095..a8026414b7 100644
--- a/InvenTree/templates/js/translated/build.js
+++ b/InvenTree/templates/js/translated/build.js
@@ -2618,6 +2618,10 @@ function loadBuildLineTable(table, build_id, options={}) {
icons += makeIconBadge('fa-tools icon-blue', `{% trans "In Production" %}: ${formatDecimal(row.in_production)}`);
}
+ if (row.external_stock > 0) {
+ icons += makeIconBadge('fa-sitemap', `{% trans "External stock" %}: ${row.external_stock}`);
+ }
+
return renderLink(text, url) + icons;
}
},
@@ -2730,6 +2734,7 @@ function loadBuildLineTable(table, build_id, options={}) {
allocateStockToBuild(build_id, [row], {
output: options.output,
+ source_location: options.location,
success: function() {
$(table).bootstrapTable('refresh');
}
diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js
index 240734e958..ffcea223d3 100644
--- a/InvenTree/templates/js/translated/part.js
+++ b/InvenTree/templates/js/translated/part.js
@@ -2804,6 +2804,15 @@ function loadPartCategoryTable(table, options) {
title: '{% trans "Parts" %}',
switchable: true,
sortable: true,
+ },
+ {
+ field: 'structural',
+ title: '{% trans "Structural" %}',
+ switchable: true,
+ sortable: true,
+ formatter: function(value) {
+ return yesNoLabel(value);
+ }
}
]
});
diff --git a/src/frontend/src/tables/bom/BomTable.tsx b/src/frontend/src/tables/bom/BomTable.tsx
index 3bbcb00744..8eacb88e78 100644
--- a/src/frontend/src/tables/bom/BomTable.tsx
+++ b/src/frontend/src/tables/bom/BomTable.tsx
@@ -142,7 +142,7 @@ export function BomTable({
},
{
accessor: 'available_stock',
-
+ sortable: true,
render: (record) => {
let extra: ReactNode[] = [];
@@ -157,6 +157,14 @@ export function BomTable({
available_stock
);
+ if (record.external_stock > 0) {
+ extra.push(
+
+ {t`External stock`}: {record.external_stock}
+
+ );
+ }
+
if (record.available_substitute_stock > 0) {
extra.push(
diff --git a/src/frontend/src/tables/build/BuildLineTable.tsx b/src/frontend/src/tables/build/BuildLineTable.tsx
index 5bd53c8801..edd93949be 100644
--- a/src/frontend/src/tables/build/BuildLineTable.tsx
+++ b/src/frontend/src/tables/build/BuildLineTable.tsx
@@ -94,6 +94,15 @@ export default function BuildLineTable({ params = {} }: { params?: any }) {
);
}
+ // Account for "external" stock
+ if (record.external_stock > 0) {
+ extra.push(
+
+ {t`External stock`}: {record.external_stock}
+
+ );
+ }
+
return (
{t`Available` + `: ${available}`}
+
+ {t`Available`}: {available}
+
+ );
+ }
+
+ if (record.external_stock > 0) {
+ extra.push(
+
+ {t`External stock`}: {record.external_stock}
+
);
}