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} + ); }