From f4233981f56868968e06eb240772d4ba14623ef5 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 25 Aug 2025 18:38:22 +1000 Subject: [PATCH] Build source location (#10220) * Display build source location * Fix docstring * Enhance build availability filter - Take build source location into account - Improve pre-fetch * Enhance type annotations --- src/backend/InvenTree/build/serializers.py | 9 ++- src/backend/InvenTree/part/filters.py | 62 ++++++++++++++------ src/frontend/src/pages/build/BuildDetail.tsx | 19 +++++- 3 files changed, 68 insertions(+), 22 deletions(-) diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 172fdd6f2e..f595266d7d 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -1519,7 +1519,10 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'allocations', 'allocations__stock_item', 'allocations__stock_item__part', + 'allocations__stock_item__supplier_part', + 'allocations__stock_item__supplier_part__manufacturer_part', 'allocations__stock_item__location', + 'allocations__stock_item__tags', 'bom_item', 'bom_item__part', 'bom_item__sub_part', @@ -1603,6 +1606,8 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali location__rght__lte=location.rght, location__level__gte=location.level, ) + else: + location = None # Annotate the "in_production" quantity queryset = queryset.annotate( @@ -1623,10 +1628,10 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali reference=ref, filter=stock_filter ), allocated_to_sales_orders=part.filters.annotate_sales_order_allocations( - reference=ref + reference=ref, location=location ), allocated_to_build_orders=part.filters.annotate_build_order_allocations( - reference=ref + reference=ref, location=location ), ) diff --git a/src/backend/InvenTree/part/filters.py b/src/backend/InvenTree/part/filters.py index 460a6c9c64..a7482036dc 100644 --- a/src/backend/InvenTree/part/filters.py +++ b/src/backend/InvenTree/part/filters.py @@ -42,7 +42,7 @@ from build.status_codes import BuildStatusGroups from order.status_codes import PurchaseOrderStatusGroups, SalesOrderStatusGroups -def annotate_in_production_quantity(reference=''): +def annotate_in_production_quantity(reference: str = '') -> QuerySet: """Annotate the 'in production' quantity for each part in a queryset. - Sum the 'quantity' field for all stock items which are 'in production' for each part. @@ -63,7 +63,7 @@ def annotate_in_production_quantity(reference=''): ) -def annotate_scheduled_to_build_quantity(reference: str = ''): +def annotate_scheduled_to_build_quantity(reference: str = '') -> QuerySet: """Annotate the 'scheduled to build' quantity for each part in a queryset. - This is total scheduled quantity for all build orders which are 'active' @@ -91,7 +91,7 @@ def annotate_scheduled_to_build_quantity(reference: str = ''): ) -def annotate_on_order_quantity(reference: str = ''): +def annotate_on_order_quantity(reference: str = '') -> QuerySet: """Annotate the 'on order' quantity for each part in a queryset. Sum the 'remaining quantity' of each line item for any open purchase orders for each part: @@ -137,7 +137,7 @@ def annotate_on_order_quantity(reference: str = ''): ) -def annotate_total_stock(reference: str = '', filter: Q = None): +def annotate_total_stock(reference: str = '', filter: Q = None) -> QuerySet: """Annotate 'total stock' quantity against a queryset. - This function calculates the 'total stock' for a given part @@ -161,7 +161,7 @@ def annotate_total_stock(reference: str = '', filter: Q = None): ) -def annotate_build_order_requirements(reference: str = ''): +def annotate_build_order_requirements(reference: str = '') -> QuerySet: """Annotate the total quantity of each part required for build orders. - Only interested in 'active' build orders @@ -179,20 +179,30 @@ def annotate_build_order_requirements(reference: str = ''): ) -def annotate_build_order_allocations(reference: str = ''): +def annotate_build_order_allocations(reference: str = '', location=None) -> QuerySet: """Annotate the total quantity of each part allocated to build orders. - This function calculates the total part quantity allocated to open build orders - Finds all build order allocations for each part (using the provided filter) - Aggregates the 'allocated quantity' for each relevant build order allocation item - Args: + Arguments: reference: The relationship reference of the part from the current model - build_filter: Q object which defines how to filter the allocation items + location: If provided, only allocated stock items from this location are considered """ # Build filter only returns 'active' build orders build_filter = Q(build_line__build__status__in=BuildStatusGroups.ACTIVE_CODES) + if location is not None: + # Filter by location (including any child locations) + + build_filter &= Q( + stock_item__location__tree_id=location.tree_id, + stock_item__location__lft__gte=location.lft, + stock_item__location__rght__lte=location.rght, + stock_item__location__level__gte=location.level, + ) + return Coalesce( SubquerySum( f'{reference}stock_items__allocations__quantity', filter=build_filter @@ -202,7 +212,7 @@ def annotate_build_order_allocations(reference: str = ''): ) -def annotate_sales_order_requirements(reference: str = ''): +def annotate_sales_order_requirements(reference: str = '') -> QuerySet: """Annotate the total quantity of each part required for sales orders. - Only interested in 'active' sales orders @@ -222,16 +232,16 @@ def annotate_sales_order_requirements(reference: str = ''): ) -def annotate_sales_order_allocations(reference: str = ''): +def annotate_sales_order_allocations(reference: str = '', location=None) -> QuerySet: """Annotate the total quantity of each part allocated to sales orders. - This function calculates the total part quantity allocated to open sales orders" - Finds all sales order allocations for each part (using the provided filter) - Aggregates the 'allocated quantity' for each relevant sales order allocation item - Args: + Arguments: reference: The relationship reference of the part from the current model - order_filter: Q object which defines how to filter the allocation items + location: If provided, only allocated stock items from this location are considered """ # Order filter only returns incomplete shipments for open orders order_filter = Q( @@ -239,6 +249,16 @@ def annotate_sales_order_allocations(reference: str = ''): shipment__shipment_date=None, ) + if location is not None: + # Filter by location (including any child locations) + + order_filter &= Q( + item__location__tree_id=location.tree_id, + item__location__lft__gte=location.lft, + item__location__rght__lte=location.rght, + item__location__level__gte=location.level, + ) + return Coalesce( SubquerySum( f'{reference}stock_items__sales_order_allocations__quantity', @@ -249,7 +269,7 @@ def annotate_sales_order_allocations(reference: str = ''): ) -def variant_stock_query(reference: str = '', filter: Q = None): +def variant_stock_query(reference: str = '', filter: Q = None) -> QuerySet: """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 @@ -270,7 +290,7 @@ def variant_stock_query(reference: str = '', filter: Q = None): ).filter(stock_filter) -def annotate_variant_quantity(subquery: Q, reference: str = 'quantity'): +def annotate_variant_quantity(subquery: Q, reference: str = 'quantity') -> QuerySet: """Create a subquery annotation for all variant part stock items on the given parent query. Args: @@ -290,7 +310,7 @@ def annotate_variant_quantity(subquery: Q, reference: str = 'quantity'): ) -def annotate_category_parts(): +def annotate_category_parts() -> QuerySet: """Construct a queryset annotation which returns the number of parts in a particular category. - Includes parts in subcategories also @@ -317,7 +337,7 @@ def annotate_category_parts(): ) -def annotate_default_location(reference=''): +def annotate_default_location(reference: str = '') -> QuerySet: """Construct a queryset that finds the closest default location in the part's category tree. If the part's category has its own default_location, this is returned. @@ -340,7 +360,7 @@ def annotate_default_location(reference=''): ) -def annotate_sub_categories(): +def annotate_sub_categories() -> QuerySet: """Construct a queryset annotation which returns the number of subcategories for each provided category.""" subquery = part.models.PartCategory.objects.filter( tree_id=OuterRef('tree_id'), @@ -504,7 +524,9 @@ def annotate_bom_item_can_build(queryset: QuerySet, reference: str = '') -> Quer PARAMETER_FILTER_OPERATORS: list[str] = ['gt', 'gte', 'lt', 'lte', 'ne', 'icontains'] -def filter_by_parameter(queryset, template_id: int, value: str, func: str = ''): +def filter_by_parameter( + queryset: QuerySet, template_id: int, value: str, func: str = '' +) -> QuerySet: """Filter the given queryset by a given template parameter. Parts which do not have a value for the given parameter are excluded. @@ -598,7 +620,9 @@ def filter_by_parameter(queryset, template_id: int, value: str, func: str = ''): return queryset.filter(q).distinct() -def order_by_parameter(queryset, template_id: int, ascending=True): +def order_by_parameter( + queryset: QuerySet, template_id: int, ascending: bool = True +) -> QuerySet: """Order the given queryset by a given template parameter. Parts which do not have a value for the given parameter are ordered last. diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index 810cc292e6..4e3944a24d 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -46,6 +46,7 @@ import NotesPanel from '../../components/panels/NotesPanel'; import type { PanelType } from '../../components/panels/Panel'; import { PanelGroup } from '../../components/panels/PanelGroup'; import { StatusRenderer } from '../../components/render/StatusRenderer'; +import { RenderStockLocation } from '../../components/render/Stock'; import { useBuildOrderFields } from '../../forms/BuildForms'; import { useCreateApiFormModal, @@ -86,6 +87,13 @@ function BuildLinesPanel({ isLoading: boolean; hasItems: boolean; }>) { + const buildLocation = useInstance({ + endpoint: ApiEndpoints.stock_location_list, + pk: build?.take_from, + hasPrimaryKey: true, + defaultValue: {} + }); + if (isLoading || !build.pk) { return ; } @@ -94,7 +102,16 @@ function BuildLinesPanel({ return ; } - return ; + return ( + + {buildLocation.instance.pk && ( + } title={t`Source Location`}> + + + )} + + + ); } function BuildAllocationsPanel({