mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +00:00 
			
		
		
		
	Merge remote-tracking branch 'origin/master' into details-updates
This commit is contained in:
		
							
								
								
									
										2
									
								
								.github/workflows/docker.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/docker.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -156,7 +156,7 @@ jobs: | |||||||
|           sbom: true |           sbom: true | ||||||
|           provenance: false |           provenance: false | ||||||
|           target: production |           target: production | ||||||
|           tags: ${{ steps.meta.outputs.tags }} |           tags: ${{ env.docker_tags }} | ||||||
|           build-args: | |           build-args: | | ||||||
|             commit_hash=${{ env.git_commit_hash }} |             commit_hash=${{ env.git_commit_hash }} | ||||||
|             commit_date=${{ env.git_commit_date }} |             commit_date=${{ env.git_commit_date }} | ||||||
|   | |||||||
| @@ -1,11 +1,17 @@ | |||||||
| """InvenTree API version information.""" | """InvenTree API version information.""" | ||||||
|  |  | ||||||
| # InvenTree API version | # 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.""" | """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" | ||||||
|  |  | ||||||
| INVENTREE_API_TEXT = """ | 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 | v177 - 2024-02-27 : https://github.com/inventree/InvenTree/pull/6581 | ||||||
|     - Adds "subcategoies" count to PartCategoryTree serializer |     - Adds "subcategoies" count to PartCategoryTree serializer | ||||||
|     - Adds "sublocations" count to StockLocationTree serializer |     - Adds "sublocations" count to StockLocationTree serializer | ||||||
|   | |||||||
| @@ -314,11 +314,21 @@ class BuildLineEndpoint: | |||||||
|     queryset = BuildLine.objects.all() |     queryset = BuildLine.objects.all() | ||||||
|     serializer_class = build.serializers.BuildLineSerializer |     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): |     def get_queryset(self): | ||||||
|         """Override queryset to select-related and annotate""" |         """Override queryset to select-related and annotate""" | ||||||
|         queryset = super().get_queryset() |         queryset = super().get_queryset() | ||||||
|  |         source_build = self.get_source_build() | ||||||
|         queryset = build.serializers.BuildLineSerializer.annotate_queryset(queryset) |         queryset = build.serializers.BuildLineSerializer.annotate_queryset(queryset, build=source_build) | ||||||
|  |  | ||||||
|         return queryset |         return queryset | ||||||
|  |  | ||||||
| @@ -353,10 +363,26 @@ class BuildLineList(BuildLineEndpoint, ListCreateAPI): | |||||||
|         'bom_item__reference', |         '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): | class BuildLineDetail(BuildLineEndpoint, RetrieveUpdateDestroyAPI): | ||||||
|     """API endpoint for detail view of a BuildLine object.""" |     """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: | class BuildOrderContextMixin: | ||||||
|   | |||||||
| @@ -1083,6 +1083,7 @@ class BuildLineSerializer(InvenTreeModelSerializer): | |||||||
|             'available_substitute_stock', |             'available_substitute_stock', | ||||||
|             'available_variant_stock', |             'available_variant_stock', | ||||||
|             'total_available_stock', |             'total_available_stock', | ||||||
|  |             'external_stock', | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
|         read_only_fields = [ |         read_only_fields = [ | ||||||
| @@ -1124,15 +1125,23 @@ class BuildLineSerializer(InvenTreeModelSerializer): | |||||||
|     available_substitute_stock = serializers.FloatField(read_only=True) |     available_substitute_stock = serializers.FloatField(read_only=True) | ||||||
|     available_variant_stock = serializers.FloatField(read_only=True) |     available_variant_stock = serializers.FloatField(read_only=True) | ||||||
|     total_available_stock = serializers.FloatField(read_only=True) |     total_available_stock = serializers.FloatField(read_only=True) | ||||||
|  |     external_stock = serializers.FloatField(read_only=True) | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def annotate_queryset(queryset): |     def annotate_queryset(queryset, build=None): | ||||||
|         """Add extra annotations to the queryset: |         """Add extra annotations to the queryset: | ||||||
|  |  | ||||||
|         - allocated: Total stock quantity allocated against this build line |         - allocated: Total stock quantity allocated against this build line | ||||||
|         - available: Total stock available for allocation against this build line |         - available: Total stock available for allocation against this build line | ||||||
|         - on_order: Total stock on order for this build line |         - on_order: Total stock on order for this build line | ||||||
|         - in_production: Total stock currently in production 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( |         queryset = queryset.select_related( | ||||||
|             'build', 'bom_item', |             'build', 'bom_item', | ||||||
| @@ -1169,6 +1178,18 @@ class BuildLineSerializer(InvenTreeModelSerializer): | |||||||
|  |  | ||||||
|         ref = 'bom_item__sub_part__' |         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 |         # Annotate the "in_production" quantity | ||||||
|         queryset = queryset.annotate( |         queryset = queryset.annotate( | ||||||
|             in_production=part.filters.annotate_in_production_quantity(reference=ref) |             in_production=part.filters.annotate_in_production_quantity(reference=ref) | ||||||
| @@ -1181,10 +1202,8 @@ class BuildLineSerializer(InvenTreeModelSerializer): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         # Annotate the "available" quantity |         # 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( |         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_sales_orders=part.filters.annotate_sales_order_allocations(reference=ref), | ||||||
|             allocated_to_build_orders=part.filters.annotate_build_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__' |         ref = 'bom_item__substitutes__part__' | ||||||
|  |  | ||||||
|         # Extract similar information for any 'substitute' parts |         # Extract similar information for any 'substitute' parts | ||||||
|         queryset = queryset.alias( |         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_build_allocations=part.filters.annotate_build_order_allocations(reference=ref), | ||||||
|             substitute_sales_allocations=part.filters.annotate_sales_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 |         # 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( |         queryset = queryset.alias( | ||||||
|             variant_stock_total=part.filters.annotate_variant_quantity(variant_stock_query, reference='quantity'), |             variant_stock_total=part.filters.annotate_variant_quantity(variant_stock_query, reference='quantity'), | ||||||
|   | |||||||
| @@ -200,6 +200,11 @@ | |||||||
|         <div id='build-lines-toolbar'> |         <div id='build-lines-toolbar'> | ||||||
|             {% include "filter_list.html" with id='buildlines' %} |             {% include "filter_list.html" with id='buildlines' %} | ||||||
|         </div> |         </div> | ||||||
|  |         {% if build.take_from %} | ||||||
|  |         <div class='alert alert-block alert-info'> | ||||||
|  |             {% trans "Available stock has been filtered based on specified source location for this build order" %} | ||||||
|  |         </div> | ||||||
|  |         {% endif %} | ||||||
|         <table class='table table-striped table-condensed' id='build-lines-table' data-toolbar='#build-lines-toolbar'></table> |         <table class='table table-striped table-condensed' id='build-lines-table' data-toolbar='#build-lines-toolbar'></table> | ||||||
|     </div> |     </div> | ||||||
| </div> | </div> | ||||||
| @@ -374,6 +379,9 @@ onPanelLoad('allocate', function() { | |||||||
|         "#build-lines-table", |         "#build-lines-table", | ||||||
|         {{ build.pk }}, |         {{ build.pk }}, | ||||||
|         { |         { | ||||||
|  |             {% if build.take_from %} | ||||||
|  |             location: {{ build.take_from.pk }}, | ||||||
|  |             {% endif %} | ||||||
|             {% if build.project_code %} |             {% if build.project_code %} | ||||||
|             project_code: {{ build.project_code.pk }}, |             project_code: {{ build.project_code.pk }}, | ||||||
|             {% endif %} |             {% endif %} | ||||||
|   | |||||||
| @@ -1767,6 +1767,7 @@ class BomFilter(rest_filters.FilterSet): | |||||||
|     part_active = rest_filters.BooleanFilter( |     part_active = rest_filters.BooleanFilter( | ||||||
|         label='Master part is active', field_name='part__active' |         label='Master part is active', field_name='part__active' | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     part_trackable = rest_filters.BooleanFilter( |     part_trackable = rest_filters.BooleanFilter( | ||||||
|         label='Master part is trackable', field_name='part__trackable' |         label='Master part is trackable', field_name='part__trackable' | ||||||
|     ) |     ) | ||||||
| @@ -1775,6 +1776,7 @@ class BomFilter(rest_filters.FilterSet): | |||||||
|     sub_part_trackable = rest_filters.BooleanFilter( |     sub_part_trackable = rest_filters.BooleanFilter( | ||||||
|         label='Sub part is trackable', field_name='sub_part__trackable' |         label='Sub part is trackable', field_name='sub_part__trackable' | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     sub_part_assembly = rest_filters.BooleanFilter( |     sub_part_assembly = rest_filters.BooleanFilter( | ||||||
|         label='Sub part is an assembly', field_name='sub_part__assembly' |         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() |         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: | class BomMixin: | ||||||
|     """Mixin class for BomItem API endpoints.""" |     """Mixin class for BomItem API endpoints.""" | ||||||
| @@ -1889,62 +1907,6 @@ class BomList(BomMixin, ListCreateDestroyAPIView): | |||||||
|             return JsonResponse(data, safe=False) |             return JsonResponse(data, safe=False) | ||||||
|         return Response(data) |         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 |     filter_backends = SEARCH_ORDER_FILTER_ALIAS | ||||||
|  |  | ||||||
|     search_fields = [ |     search_fields = [ | ||||||
|   | |||||||
| @@ -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. |     """Annotate 'total stock' quantity against a queryset. | ||||||
|  |  | ||||||
|     - This function calculates the 'total stock' for a given part |     - 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 only returns 'in stock' items | ||||||
|     stock_filter = stock.models.StockItem.IN_STOCK_FILTER |     stock_filter = stock.models.StockItem.IN_STOCK_FILTER | ||||||
|  |  | ||||||
|  |     if filter is not None: | ||||||
|  |         stock_filter &= filter | ||||||
|  |  | ||||||
|     return Coalesce( |     return Coalesce( | ||||||
|         SubquerySum(f'{reference}stock_items__quantity', filter=stock_filter), |         SubquerySum(f'{reference}stock_items__quantity', filter=stock_filter), | ||||||
|         Decimal(0), |         Decimal(0), | ||||||
| @@ -216,9 +219,7 @@ def annotate_sales_order_allocations(reference: str = ''): | |||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| def variant_stock_query( | def variant_stock_query(reference: str = '', filter: Q = None): | ||||||
|     reference: str = '', filter: Q = stock.models.StockItem.IN_STOCK_FILTER |  | ||||||
| ): |  | ||||||
|     """Create a queryset to retrieve all stock items for variant parts under the specified part. |     """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 |     - 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 |         reference: The relationship reference of the part from the current model | ||||||
|         filter: Q object which defines how to filter the returned StockItem instances |         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( |     return stock.models.StockItem.objects.filter( | ||||||
|         part__tree_id=OuterRef(f'{reference}tree_id'), |         part__tree_id=OuterRef(f'{reference}tree_id'), | ||||||
|         part__lft__gt=OuterRef(f'{reference}lft'), |         part__lft__gt=OuterRef(f'{reference}lft'), | ||||||
|         part__rght__lt=OuterRef(f'{reference}rght'), |         part__rght__lt=OuterRef(f'{reference}rght'), | ||||||
|     ).filter(filter) |     ).filter(stock_filter) | ||||||
|  |  | ||||||
|  |  | ||||||
| def annotate_variant_quantity(subquery: Q, reference: str = 'quantity'): | def annotate_variant_quantity(subquery: Q, reference: str = 'quantity'): | ||||||
|   | |||||||
| @@ -610,6 +610,7 @@ class PartSerializer( | |||||||
|             'stock_item_count', |             'stock_item_count', | ||||||
|             'suppliers', |             'suppliers', | ||||||
|             'total_in_stock', |             'total_in_stock', | ||||||
|  |             'external_stock', | ||||||
|             'unallocated_stock', |             'unallocated_stock', | ||||||
|             'variant_stock', |             'variant_stock', | ||||||
|             # Fields only used for Part creation |             # 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 |         # Annotate with the total 'available stock' quantity | ||||||
|         # This is the current stock, minus any allocations |         # This is the current stock, minus any allocations | ||||||
|         queryset = queryset.annotate( |         queryset = queryset.annotate( | ||||||
| @@ -780,14 +787,17 @@ class PartSerializer( | |||||||
|     allocated_to_sales_orders = serializers.FloatField(read_only=True) |     allocated_to_sales_orders = serializers.FloatField(read_only=True) | ||||||
|     building = serializers.FloatField(read_only=True) |     building = serializers.FloatField(read_only=True) | ||||||
|     in_stock = 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_build_orders = serializers.IntegerField(read_only=True) | ||||||
|     required_for_sales_orders = serializers.IntegerField(read_only=True) |     required_for_sales_orders = serializers.IntegerField(read_only=True) | ||||||
|     stock_item_count = serializers.IntegerField(read_only=True) |     stock_item_count = serializers.IntegerField(read_only=True, label=_('Stock Items')) | ||||||
|     suppliers = serializers.IntegerField(read_only=True) |     suppliers = serializers.IntegerField(read_only=True, label=_('Suppliers')) | ||||||
|     total_in_stock = serializers.FloatField(read_only=True) |     total_in_stock = serializers.FloatField(read_only=True, label=_('Total Stock')) | ||||||
|     unallocated_stock = serializers.FloatField(read_only=True) |     external_stock = serializers.FloatField(read_only=True, label=_('External Stock')) | ||||||
|     variant_stock = serializers.FloatField(read_only=True) |     unallocated_stock = serializers.FloatField( | ||||||
|  |         read_only=True, label=_('Unallocated Stock') | ||||||
|  |     ) | ||||||
|  |     variant_stock = serializers.FloatField(read_only=True, label=_('Variant Stock')) | ||||||
|  |  | ||||||
|     minimum_stock = serializers.FloatField() |     minimum_stock = serializers.FloatField() | ||||||
|  |  | ||||||
| @@ -1387,6 +1397,7 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): | |||||||
|             'available_stock', |             'available_stock', | ||||||
|             'available_substitute_stock', |             'available_substitute_stock', | ||||||
|             'available_variant_stock', |             'available_variant_stock', | ||||||
|  |             'external_stock', | ||||||
|             # Annotated field describing quantity on order |             # Annotated field describing quantity on order | ||||||
|             'on_order', |             'on_order', | ||||||
|             # Annotated field describing quantity being built |             # Annotated field describing quantity being built | ||||||
| @@ -1456,6 +1467,8 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): | |||||||
|     available_substitute_stock = serializers.FloatField(read_only=True) |     available_substitute_stock = serializers.FloatField(read_only=True) | ||||||
|     available_variant_stock = serializers.FloatField(read_only=True) |     available_variant_stock = serializers.FloatField(read_only=True) | ||||||
|  |  | ||||||
|  |     external_stock = serializers.FloatField(read_only=True) | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def setup_eager_loading(queryset): |     def setup_eager_loading(queryset): | ||||||
|         """Prefetch against the provided queryset to speed up database access.""" |         """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__' |         ref = 'substitutes__part__' | ||||||
|  |  | ||||||
|         # Extract similar information for any 'substitute' parts |         # Extract similar information for any 'substitute' parts | ||||||
|   | |||||||
| @@ -1172,12 +1172,18 @@ function loadBomTable(table, options={}) { | |||||||
|  |  | ||||||
|             var available_stock = availableQuantity(row); |             var available_stock = availableQuantity(row); | ||||||
|  |  | ||||||
|  |             var external_stock = row.external_stock ?? 0; | ||||||
|  |  | ||||||
|             var text = renderLink(`${available_stock}`, url); |             var text = renderLink(`${available_stock}`, url); | ||||||
|  |  | ||||||
|             if (row.sub_part_detail && row.sub_part_detail.units) { |             if (row.sub_part_detail && row.sub_part_detail.units) { | ||||||
|                 text += ` <small>${row.sub_part_detail.units}</small>`; |                 text += ` <small>${row.sub_part_detail.units}</small>`; | ||||||
|             } |             } | ||||||
|  |  | ||||||
|  |             if (external_stock > 0) { | ||||||
|  |                 text += makeIconBadge('fa-sitemap', `{% trans "External stock" %}: ${external_stock}`); | ||||||
|  |             } | ||||||
|  |  | ||||||
|             if (available_stock <= 0) { |             if (available_stock <= 0) { | ||||||
|                 text += makeIconBadge('fa-times-circle icon-red', '{% trans "No Stock Available" %}'); |                 text += makeIconBadge('fa-times-circle icon-red', '{% trans "No Stock Available" %}'); | ||||||
|             } else { |             } else { | ||||||
|   | |||||||
| @@ -2618,6 +2618,10 @@ function loadBuildLineTable(table, build_id, options={}) { | |||||||
|                         icons += makeIconBadge('fa-tools icon-blue', `{% trans "In Production" %}: ${formatDecimal(row.in_production)}`); |                         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; |                     return renderLink(text, url) + icons; | ||||||
|                 } |                 } | ||||||
|             }, |             }, | ||||||
| @@ -2730,6 +2734,7 @@ function loadBuildLineTable(table, build_id, options={}) { | |||||||
|  |  | ||||||
|         allocateStockToBuild(build_id, [row], { |         allocateStockToBuild(build_id, [row], { | ||||||
|             output: options.output, |             output: options.output, | ||||||
|  |             source_location: options.location, | ||||||
|             success: function() { |             success: function() { | ||||||
|                 $(table).bootstrapTable('refresh'); |                 $(table).bootstrapTable('refresh'); | ||||||
|             } |             } | ||||||
|   | |||||||
| @@ -2804,6 +2804,15 @@ function loadPartCategoryTable(table, options) { | |||||||
|                 title: '{% trans "Parts" %}', |                 title: '{% trans "Parts" %}', | ||||||
|                 switchable: true, |                 switchable: true, | ||||||
|                 sortable: true, |                 sortable: true, | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 field: 'structural', | ||||||
|  |                 title: '{% trans "Structural" %}', | ||||||
|  |                 switchable: true, | ||||||
|  |                 sortable: true, | ||||||
|  |                 formatter: function(value) { | ||||||
|  |                     return yesNoLabel(value); | ||||||
|  |                 } | ||||||
|             } |             } | ||||||
|         ] |         ] | ||||||
|     }); |     }); | ||||||
|   | |||||||
| @@ -142,7 +142,7 @@ export function BomTable({ | |||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         accessor: 'available_stock', |         accessor: 'available_stock', | ||||||
|  |         sortable: true, | ||||||
|         render: (record) => { |         render: (record) => { | ||||||
|           let extra: ReactNode[] = []; |           let extra: ReactNode[] = []; | ||||||
|  |  | ||||||
| @@ -157,6 +157,14 @@ export function BomTable({ | |||||||
|               available_stock |               available_stock | ||||||
|             ); |             ); | ||||||
|  |  | ||||||
|  |           if (record.external_stock > 0) { | ||||||
|  |             extra.push( | ||||||
|  |               <Text key="external"> | ||||||
|  |                 {t`External stock`}: {record.external_stock} | ||||||
|  |               </Text> | ||||||
|  |             ); | ||||||
|  |           } | ||||||
|  |  | ||||||
|           if (record.available_substitute_stock > 0) { |           if (record.available_substitute_stock > 0) { | ||||||
|             extra.push( |             extra.push( | ||||||
|               <Text key="substitute"> |               <Text key="substitute"> | ||||||
|   | |||||||
| @@ -94,6 +94,15 @@ export default function BuildLineTable({ params = {} }: { params?: any }) { | |||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // Account for "external" stock | ||||||
|  |     if (record.external_stock > 0) { | ||||||
|  |       extra.push( | ||||||
|  |         <Text key="external" size="sm"> | ||||||
|  |           {t`External stock`}: {record.external_stock} | ||||||
|  |         </Text> | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|       <TableHoverCard |       <TableHoverCard | ||||||
|         value={ |         value={ | ||||||
|   | |||||||
| @@ -113,7 +113,17 @@ function partTableColumns(): TableColumn[] { | |||||||
|  |  | ||||||
|         if (available != stock) { |         if (available != stock) { | ||||||
|           extra.push( |           extra.push( | ||||||
|             <Text key="available">{t`Available` + `: ${available}`}</Text> |             <Text key="available"> | ||||||
|  |               {t`Available`}: {available} | ||||||
|  |             </Text> | ||||||
|  |           ); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (record.external_stock > 0) { | ||||||
|  |           extra.push( | ||||||
|  |             <Text key="external"> | ||||||
|  |               {t`External stock`}: {record.external_stock} | ||||||
|  |             </Text> | ||||||
|           ); |           ); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user