From a9c8c47e601a0c4b42f4d94cc4ec3e6e88d58fda Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 18 Jul 2025 00:00:33 +1000 Subject: [PATCH] [UI] Part requirements (#10036) * Tweak SalesOrderAllocations table * Refactor "include_variants" filter * Improved API filtering for "SalesOrderLineItem" endpoint * Fetch part detail for table * Fix email template - Referenced template which does not exist * Refactor the "requirements" endpoint - Include variant part requirements too * Updated starred notifications * Adjust column name * Update PartDetail - Extract information from partRequirements query first * Cache BOM items * Improve PartDetail page * Enhance isGeneratingSchema - Call inspect as late as possible * Adjust PartDetail * Improve BuildOrderAllocations table * Exclude common.newsfeedentry when exporting * Updated playwright tests * Bump API version --- .../InvenTree/InvenTree/api_version.py | 7 +- src/backend/InvenTree/InvenTree/ready.py | 7 + src/backend/InvenTree/build/api.py | 1 + src/backend/InvenTree/build/serializers.py | 13 ++ src/backend/InvenTree/order/api.py | 32 ++++- src/backend/InvenTree/part/models.py | 123 ++++++++++++++---- src/backend/InvenTree/part/serializers.py | 2 +- .../email/build_order_required_stock.html | 4 +- .../buttons/StarredToggleButton.tsx | 6 +- src/frontend/src/pages/part/PartDetail.tsx | 108 ++++++++------- src/frontend/src/tables/Filter.tsx | 9 ++ .../tables/build/BuildAllocatedStockTable.tsx | 9 +- .../src/tables/build/BuildOrderTable.tsx | 8 +- .../src/tables/build/BuildOutputTable.tsx | 1 + .../tables/part/PartBuildAllocationsTable.tsx | 29 ++++- .../src/tables/part/PartParameterTable.tsx | 8 +- .../tables/part/PartPurchaseOrdersTable.tsx | 9 +- .../tables/part/PartSalesAllocationsTable.tsx | 24 +++- .../src/tables/sales/ReturnOrderTable.tsx | 8 +- .../sales/SalesOrderAllocationTable.tsx | 9 +- .../src/tables/sales/SalesOrderTable.tsx | 8 +- .../src/tables/stock/StockItemTable.tsx | 7 +- src/frontend/tests/pages/pui_part.spec.ts | 39 ++++++ tasks.py | 1 + 24 files changed, 338 insertions(+), 134 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 00a754e330..91c1adecd4 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,17 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 369 +INVENTREE_API_VERSION = 370 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v370 -> 2025-07-17 : https://github.com/inventree/InvenTree/pull/10036 + - Adds optional "assembly_detail" information to BuildLine API endpoint + - Adds "include_variants" filter to SalesOrderLineItem API endpoint + - Improves the "PartRequirements" API endpoint to include variant aggregations + v369 -> 2025-07-15 : https://github.com/inventree/InvenTree/pull/10023 - Adds "note", "updated", "updated_by" fields to the PartParameter API endpoints diff --git a/src/backend/InvenTree/InvenTree/ready.py b/src/backend/InvenTree/InvenTree/ready.py index 8875520098..fd4aa565d6 100644 --- a/src/backend/InvenTree/InvenTree/ready.py +++ b/src/backend/InvenTree/InvenTree/ready.py @@ -50,12 +50,19 @@ def isGeneratingSchema(): if isInServerThread() or isInWorkerThread(): return False + if isRunningMigrations() or isRunningBackup() or isRebuildingData(): + return False + + if isImportingData(): + return False + if isInTestMode(): return False if 'schema' in sys.argv: return True + # This is a very inefficient call - so we only use it as a last resort return any('drf_spectacular' in frame.filename for frame in inspect.stack()) diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index ad0f229443..6bd0d51e97 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -533,6 +533,7 @@ class BuildLineEndpoint: params = self.request.query_params kwargs['bom_item_detail'] = str2bool(params.get('bom_item_detail', True)) + kwargs['assembly_detail'] = str2bool(params.get('assembly_detail', True)) kwargs['part_detail'] = str2bool(params.get('part_detail', True)) kwargs['build_detail'] = str2bool(params.get('build_detail', False)) kwargs['allocations'] = str2bool(params.get('allocations', True)) diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index f16e029b91..a5f15a6939 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -1319,6 +1319,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali 'part_category_name', # Extra detail (related field) serializers 'bom_item_detail', + 'assembly_detail', 'part_detail', 'build_detail', ] @@ -1328,6 +1329,7 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali def __init__(self, *args, **kwargs): """Determine which extra details fields should be included.""" part_detail = kwargs.pop('part_detail', True) + assembly_detail = kwargs.pop('assembly_detail', True) bom_item_detail = kwargs.pop('bom_item_detail', True) build_detail = kwargs.pop('build_detail', True) allocations = kwargs.pop('allocations', True) @@ -1349,6 +1351,9 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali if not allocations: self.fields.pop('allocations', None) + if not assembly_detail: + self.fields.pop('assembly_detail', None) + # Build info fields build_reference = serializers.CharField( source='build.reference', label=_('Build Reference'), read_only=True @@ -1406,6 +1411,14 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali part_detail=False, ) + assembly_detail = part_serializers.PartBriefSerializer( + label=_('Assembly'), + source='bom_item.part', + many=False, + read_only=True, + pricing=False, + ) + part_detail = part_serializers.PartBriefSerializer( label=_('Part'), source='bom_item.sub_part', diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 2847933e8f..35ca30f849 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -894,10 +894,40 @@ class SalesOrderLineItemFilter(LineItemFilter): queryset=models.SalesOrder.objects.all(), field_name='order', label=_('Order') ) + def filter_include_variants(self, queryset, name, value): + """Filter by whether or not to include variants of the selected part. + + Note: + - This filter does nothing by itself, and requires the 'part' filter to be set. + - Refer to the 'filter_part' method for more information. + """ + return queryset + part = rest_filters.ModelChoiceFilter( - queryset=Part.objects.all(), field_name='part', label=_('Part') + queryset=Part.objects.all(), + field_name='part', + label=_('Part'), + method='filter_part', ) + @extend_schema_field(OpenApiTypes.INT) + def filter_part(self, queryset, name, part): + """Filter SalesOrderLineItem by selected 'part'. + + Note: + - If 'include_variants' is set to True, then all variants of the selected part will be included. + - Otherwise, just filter by the selected part. + """ + include_variants = str2bool(self.data.get('include_variants', False)) + + # Construct a queryset of parts to filter by + if include_variants: + parts = part.get_descendants(include_self=True) + else: + parts = Part.objects.filter(pk=part.pk) + + return queryset.filter(part__in=parts) + allocated = rest_filters.BooleanFilter( label=_('Allocated'), method='filter_allocated' ) diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 57fe3f21d7..c3ee9d4533 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -1330,29 +1330,62 @@ class Part( return max(total, 0) - def requiring_build_orders(self): - """Return list of outstanding build orders which require this part.""" + def requiring_build_orders(self, include_variants: bool = True): + """Return list of outstanding build orders which require this part. + + Arguments: + include_variants: If True, include variants of this part in the calculation + """ # List parts that this part is required for + if include_variants: + # If we are including variants, get all parts in the variant tree + parts = list(self.get_descendants(include_self=True)) + else: + parts = [self] + + used_in_parts = set() + + for part in parts: + # Get all assemblies which use this part + used_in_parts.update(part.get_used_in()) + # Now, get a list of outstanding build orders which require this part builds = BuildModels.Build.objects.filter( - part__in=self.get_used_in(), status__in=BuildStatusGroups.ACTIVE_CODES + part__in=list(used_in_parts), status__in=BuildStatusGroups.ACTIVE_CODES ) return builds - def required_build_order_quantity(self): - """Return the quantity of this part required for active build orders.""" + def required_build_order_quantity(self, include_variants: bool = True): + """Return the quantity of this part required for active build orders. + + Arguments: + include_variants: If True, include variants of this part in the calculation + """ # List active build orders which reference this part - builds = self.requiring_build_orders() + builds = self.requiring_build_orders(include_variants=include_variants) quantity = 0 - for build in builds: - bom_item = None + if include_variants: + matching_parts = list(self.get_descendants(include_self=True)) + else: + matching_parts = [self] - # List the bom lines required to make the build (including inherited ones!) - bom_items = build.part.get_bom_items().filter(sub_part=self) + # Cache the BOM items that we query + # Keep a dict of part ID to BOM items + cached_bom_items: dict = {} + + for build in builds: + if build.part.pk not in cached_bom_items: + # Get the BOM items for this part + bom_items = build.part.get_bom_items().filter( + sub_part__in=matching_parts + ) + cached_bom_items[build.part.pk] = bom_items + else: + bom_items = cached_bom_items[build.part.pk] # Match BOM item to build for bom_item in bom_items: @@ -1362,13 +1395,22 @@ class Part( return quantity - def requiring_sales_orders(self): - """Return a list of sales orders which require this part.""" + def requiring_sales_orders(self, include_variants: bool = True): + """Return a list of sales orders which require this part. + + Arguments: + include_variants: If True, include variants of this part in the calculation + """ orders = set() + if include_variants: + parts = list(self.get_descendants(include_self=True)) + else: + parts = [self] + # Get a list of line items for open orders which match this part open_lines = OrderModels.SalesOrderLineItem.objects.filter( - order__status__in=SalesOrderStatusGroups.OPEN, part=self + order__status__in=SalesOrderStatusGroups.OPEN, part__in=parts ) for line in open_lines: @@ -1376,11 +1418,20 @@ class Part( return orders - def required_sales_order_quantity(self): - """Return the quantity of this part required for active sales orders.""" + def required_sales_order_quantity(self, include_variants: bool = True): + """Return the quantity of this part required for active sales orders. + + Arguments: + include_variants: If True, include variants of this part in the calculation + """ + if include_variants: + parts = list(self.get_descendants(include_self=True)) + else: + parts = [self] + # Get a list of line items for open orders which match this part open_lines = OrderModels.SalesOrderLineItem.objects.filter( - order__status__in=SalesOrderStatusGroups.OPEN, part=self + order__status__in=SalesOrderStatusGroups.OPEN, part__in=parts ) quantity = 0 @@ -1392,11 +1443,11 @@ class Part( return quantity - def required_order_quantity(self): + def required_order_quantity(self, include_variants: bool = True): """Return total required to fulfil orders.""" - return ( - self.required_build_order_quantity() + self.required_sales_order_quantity() - ) + return self.required_build_order_quantity( + include_variants=include_variants + ) + self.required_sales_order_quantity(include_variants=include_variants) @property def quantity_to_order(self): @@ -1626,13 +1677,25 @@ class Part( return self.builds.filter(status__in=BuildStatusGroups.ACTIVE_CODES) @property - def quantity_being_built(self): + def quantity_being_built(self, include_variants: bool = True): """Return the current number of parts currently being built. + Arguments: + include_variants: If True, include variants of this part in the calculation + Note: This is the total quantity of Build orders, *not* the number of build outputs. In this fashion, it is the "projected" quantity of builds """ - builds = self.active_builds + builds = BuildModels.Build.objects.filter( + status__in=BuildStatusGroups.ACTIVE_CODES + ) + + if include_variants: + # If we are including variants, get all parts in the variant tree + builds = builds.filter(part__in=self.get_descendants(include_self=True)) + else: + # Only look at this part + builds = builds.filter(part=self) quantity = 0 @@ -1643,17 +1706,27 @@ class Part( return quantity @property - def quantity_in_production(self): + def quantity_in_production(self, include_variants: bool = True): """Quantity of this part currently actively in production. + Arguments: + include_variants: If True, include variants of this part in the calculation + Note: This may return a different value to `quantity_being_built` """ quantity = 0 - items = self.stock_items.filter( + items = StockModels.StockItem.objects.filter( is_building=True, build__status__in=BuildStatusGroups.ACTIVE_CODES ) + if include_variants: + # If we are including variants, get all parts in the variant tree + items = items.filter(part__in=self.get_descendants(include_self=True)) + else: + # Only look at this part + items = items.filter(part=self) + for item in items: # The remaining items in the build quantity += item.quantity @@ -1823,7 +1896,7 @@ class Part( self.get_bom_item_filter(include_inherited=include_inherited) ) - return queryset.prefetch_related('sub_part') + return queryset.prefetch_related('part', 'sub_part') def get_installed_part_options( self, include_inherited: bool = True, include_variants: bool = True diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 3fef3b1634..3d0db4c68c 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -1277,7 +1277,7 @@ class PartRequirementsSerializer(InvenTree.serializers.InvenTreeModelSerializer) def get_allocated_to_sales_orders(self, part) -> float: """Return the allocated sales order quantity.""" - return part.sales_order_allocation_count(pending=True) + return part.sales_order_allocation_count(include_variants=True, pending=True) class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer): diff --git a/src/backend/InvenTree/templates/email/build_order_required_stock.html b/src/backend/InvenTree/templates/email/build_order_required_stock.html index d586575d9a..fa1100afd6 100644 --- a/src/backend/InvenTree/templates/email/build_order_required_stock.html +++ b/src/backend/InvenTree/templates/email/build_order_required_stock.html @@ -25,9 +25,9 @@ {{ line.part.full_name }}{% if line.part.description %} - {{ line.part.description }}{% endif %} - {% decimal line.required %} {% include "part/part_units.html" with part=line.part %} + {% decimal line.required %} - {% decimal line.available %} {% include "part/part_units.html" with part=line.part %} + {% decimal line.available %} {% endfor %} diff --git a/src/frontend/src/components/buttons/StarredToggleButton.tsx b/src/frontend/src/components/buttons/StarredToggleButton.tsx index f9c6fc036a..1fc85c3909 100644 --- a/src/frontend/src/components/buttons/StarredToggleButton.tsx +++ b/src/frontend/src/components/buttons/StarredToggleButton.tsx @@ -3,7 +3,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { apiUrl } from '@lib/functions/Api'; import { t } from '@lingui/core/macro'; -import { showNotification } from '@mantine/notifications'; +import { hideNotification, showNotification } from '@mantine/notifications'; import { IconBell } from '@tabler/icons-react'; import type { JSX } from 'react'; import { useApi } from '../../contexts/ApiContext'; @@ -31,8 +31,10 @@ export default function StarredToggleButton({ { starred: !starred } ) .then(() => { + hideNotification('subscription-update'); showNotification({ - title: 'Subscription updated', + title: t`Subscription Updated`, + id: 'subscription-update', message: `Subscription ${starred ? 'removed' : 'added'}`, autoClose: 5000, color: 'blue' diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index c1a23c45b9..9be7f5171c 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -153,6 +153,16 @@ export default function PartDetail() { const data = { ...part }; + const fetching = + partRequirementsQuery.isFetching || instanceQuery.isFetching; + + // Copy part requirements data into the main part data + data.total_in_stock = + partRequirements?.total_stock ?? part?.total_in_stock ?? 0; + data.unallocated = + partRequirements?.unallocated_stock ?? part?.unallocated_stock ?? 0; + data.ordering = partRequirements?.ordering ?? part?.ordering ?? 0; + data.required = (partRequirements?.required_for_build_orders ?? part?.required_for_build_orders ?? @@ -273,26 +283,12 @@ export default function PartDetail() { label: t`In Stock` }, { - type: 'number', + type: 'progressbar', name: 'unallocated_stock', - unit: part.units, + total: data.total_in_stock, + progress: data.unallocated, label: t`Available Stock`, - hidden: part.total_in_stock == part.unallocated_stock - }, - { - type: 'number', - name: 'variant_stock', - unit: part.units, - label: t`Variant Stock`, - hidden: !part.variant_stock, - icon: 'stock' - }, - { - type: 'number', - name: 'minimum_stock', - unit: part.units, - label: t`Minimum Stock`, - hidden: part.minimum_stock <= 0 + hidden: data.total_in_stock == data.unallocated }, { type: 'number', @@ -306,47 +302,56 @@ export default function PartDetail() { name: 'required', label: t`Required for Orders`, unit: part.units, - hidden: part.required <= 0, + hidden: data.required <= 0, icon: 'stocktake' }, { type: 'progressbar', name: 'allocated_to_build_orders', - icon: 'tick_off', - total: part.required_for_build_orders, - progress: part.allocated_to_build_orders, + icon: 'manufacturers', + total: partRequirements.required_for_build_orders, + progress: partRequirements.allocated_to_build_orders, label: t`Allocated to Build Orders`, hidden: - !part.component || - (part.required_for_build_orders <= 0 && - part.allocated_to_build_orders <= 0) + fetching || + (partRequirements.required_for_build_orders <= 0 && + partRequirements.allocated_to_build_orders <= 0) }, { type: 'progressbar', - icon: 'tick_off', + icon: 'sales_orders', name: 'allocated_to_sales_orders', - total: part.required_for_sales_orders, - progress: part.allocated_to_sales_orders, + total: partRequirements.required_for_sales_orders, + progress: partRequirements.allocated_to_sales_orders, label: t`Allocated to Sales Orders`, hidden: - !part.salable || - (part.required_for_sales_orders <= 0 && - part.allocated_to_sales_orders <= 0) + fetching || + (partRequirements.required_for_sales_orders <= 0 && + partRequirements.allocated_to_sales_orders <= 0) }, { type: 'progressbar', name: 'building', label: t`In Production`, - progress: part.building, - total: part.scheduled_to_build, - hidden: !part.assembly || (!part.building && !part.scheduled_to_build) + progress: partRequirements.building, + total: partRequirements.scheduled_to_build, + hidden: + fetching || + (!partRequirements.building && !partRequirements.scheduled_to_build) }, { type: 'number', name: 'can_build', unit: part.units, label: t`Can Build`, - hidden: !part.assembly || partRequirementsQuery.isFetching + hidden: !part.assembly || fetching + }, + { + type: 'number', + name: 'minimum_stock', + unit: part.units, + label: t`Minimum Stock`, + hidden: part.minimum_stock <= 0 } ]; @@ -768,30 +773,37 @@ export default function PartDetail() { }, [part]); const badges: ReactNode[] = useMemo(() => { - if (instanceQuery.isFetching) { + if (partRequirementsQuery.isFetching) { return []; } const required = - part.required_for_build_orders + part.required_for_sales_orders; + partRequirements.required_for_build_orders + + partRequirements.required_for_sales_orders; return [ = part.minimum_stock ? 'green' : 'orange'} - visible={part.total_in_stock > 0} + label={`${t`In Stock`}: ${partRequirements.total_stock}`} + color={ + partRequirements.total_stock >= part.minimum_stock + ? 'green' + : 'orange' + } + visible={partRequirements.total_stock > 0} key='in_stock' />, , , , 0} + visible={partRequirements.ordering > 0} key='on_order' />, 0} + visible={partRequirements.building > 0} key='in_production' />, ]; - }, [part, instanceQuery.isFetching]); + }, [partRequirements, partRequirementsQuery.isFetching, part]); const partFields = usePartFields({ create: false }); diff --git a/src/frontend/src/tables/Filter.tsx b/src/frontend/src/tables/Filter.tsx index 4a0edc6cc7..f42496a267 100644 --- a/src/frontend/src/tables/Filter.tsx +++ b/src/frontend/src/tables/Filter.tsx @@ -249,6 +249,15 @@ export function HasProjectCodeFilter(): TableFilter { }; } +export function IncludeVariantsFilter(): TableFilter { + return { + name: 'include_variants', + type: 'boolean', + label: t`Include Variants`, + description: t`Include results for part variants` + }; +} + export function OrderStatusFilter({ model }: { model: ModelType }): TableFilter { diff --git a/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx b/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx index 55c8fd1f81..18d1760b99 100644 --- a/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx +++ b/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx @@ -26,7 +26,7 @@ import { ReferenceColumn, StatusColumn } from '../ColumnRenderers'; -import { StockLocationFilter } from '../Filter'; +import { IncludeVariantsFilter, StockLocationFilter } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; /** @@ -66,12 +66,7 @@ export default function BuildAllocatedStockTable({ ]; if (!!partId) { - filters.push({ - name: 'include_variants', - type: 'boolean', - label: t`Include Variants`, - description: t`Include orders for part variants` - }); + filters.push(IncludeVariantsFilter()); } filters.push(StockLocationFilter()); diff --git a/src/frontend/src/tables/build/BuildOrderTable.tsx b/src/frontend/src/tables/build/BuildOrderTable.tsx index 1009f60bf8..7f94d979e5 100644 --- a/src/frontend/src/tables/build/BuildOrderTable.tsx +++ b/src/frontend/src/tables/build/BuildOrderTable.tsx @@ -33,6 +33,7 @@ import { CreatedAfterFilter, CreatedBeforeFilter, HasProjectCodeFilter, + IncludeVariantsFilter, IssuedByFilter, MaxDateFilter, MinDateFilter, @@ -190,12 +191,7 @@ export function BuildOrderTable({ // If we are filtering on a specific part, we can include the "include variants" filter if (!!partId) { - filters.push({ - name: 'include_variants', - type: 'boolean', - label: t`Include Variants`, - description: t`Include orders for part variants` - }); + filters.push(IncludeVariantsFilter()); } return filters; diff --git a/src/frontend/src/tables/build/BuildOutputTable.tsx b/src/frontend/src/tables/build/BuildOutputTable.tsx index b16ec79467..51cea65eda 100644 --- a/src/frontend/src/tables/build/BuildOutputTable.tsx +++ b/src/frontend/src/tables/build/BuildOutputTable.tsx @@ -285,6 +285,7 @@ export default function BuildOutputTable({ title: t`Add Build Output`, modalId: 'add-build-output', fields: buildOutputFields, + successMessage: t`Build output created`, timeout: 10000, initialData: { batch_code: build.batch, diff --git a/src/frontend/src/tables/part/PartBuildAllocationsTable.tsx b/src/frontend/src/tables/part/PartBuildAllocationsTable.tsx index 57aba5a152..5231e3d66f 100644 --- a/src/frontend/src/tables/part/PartBuildAllocationsTable.tsx +++ b/src/frontend/src/tables/part/PartBuildAllocationsTable.tsx @@ -10,6 +10,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import { apiUrl } from '@lib/functions/Api'; +import type { TableFilter } from '@lib/types/Filters'; import type { TableColumn } from '@lib/types/Tables'; import { useTable } from '../../hooks/UseTable'; import { useUserState } from '../../states/UserState'; @@ -19,6 +20,7 @@ import { ProjectCodeColumn, StatusColumn } from '../ColumnRenderers'; +import { IncludeVariantsFilter } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; import RowExpansionIcon from '../RowExpansionIcon'; import { BuildLineSubTable } from '../build/BuildLineTable'; @@ -53,10 +55,26 @@ export default function PartBuildAllocationsTable({ ) }, { - accessor: 'part', + accessor: 'assembly_detail', title: t`Assembly`, + switchable: false, + render: (record: any) => + }, + { + accessor: 'assembly_detail.IPN', + title: t`Assembly IPN` + }, + { + accessor: 'part_detail', + title: t`Part`, + defaultVisible: false, render: (record: any) => }, + { + accessor: 'part_detail.IPN', + defaultVisible: false, + title: t`Part IPN` + }, DescriptionColumn({ accessor: 'build_detail.title' }), @@ -114,6 +132,10 @@ export default function PartBuildAllocationsTable({ }; }, [table.isRowExpanded]); + const tableFilters: TableFilter[] = useMemo(() => { + return [IncludeVariantsFilter()]; + }, []); + return ( ); diff --git a/src/frontend/src/tables/part/PartParameterTable.tsx b/src/frontend/src/tables/part/PartParameterTable.tsx index 0b2a2930bb..11313c7c20 100644 --- a/src/frontend/src/tables/part/PartParameterTable.tsx +++ b/src/frontend/src/tables/part/PartParameterTable.tsx @@ -32,7 +32,7 @@ import { NoteColumn, PartColumn } from '../ColumnRenderers'; -import { UserFilter } from '../Filter'; +import { IncludeVariantsFilter, UserFilter } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; import { TableHoverCard } from '../TableHoverCard'; @@ -142,11 +142,7 @@ export function PartParameterTable({ const tableFilters: TableFilter[] = useMemo(() => { return [ - { - name: 'include_variants', - label: t`Include Variants`, - type: 'boolean' - }, + IncludeVariantsFilter(), UserFilter({ name: 'updated_by', label: t`Updated By`, diff --git a/src/frontend/src/tables/part/PartPurchaseOrdersTable.tsx b/src/frontend/src/tables/part/PartPurchaseOrdersTable.tsx index c487f5e49f..fe777484ba 100644 --- a/src/frontend/src/tables/part/PartPurchaseOrdersTable.tsx +++ b/src/frontend/src/tables/part/PartPurchaseOrdersTable.tsx @@ -11,7 +11,7 @@ import type { TableColumn } from '@lib/types/Tables'; import { formatCurrency } from '../../defaults/formatters'; import { useTable } from '../../hooks/UseTable'; import { DateColumn, ReferenceColumn, StatusColumn } from '../ColumnRenderers'; -import { StatusFilterOptions } from '../Filter'; +import { IncludeVariantsFilter, StatusFilterOptions } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; import { TableHoverCard } from '../TableHoverCard'; @@ -133,12 +133,7 @@ export default function PartPurchaseOrdersTable({ description: t`Filter by order status`, choiceFunction: StatusFilterOptions(ModelType.purchaseorder) }, - { - name: 'include_variants', - type: 'boolean', - label: t`Include Variants`, - description: t`Include orders for part variants` - } + IncludeVariantsFilter() ]; }, []); diff --git a/src/frontend/src/tables/part/PartSalesAllocationsTable.tsx b/src/frontend/src/tables/part/PartSalesAllocationsTable.tsx index 01c3b64389..10f7630a12 100644 --- a/src/frontend/src/tables/part/PartSalesAllocationsTable.tsx +++ b/src/frontend/src/tables/part/PartSalesAllocationsTable.tsx @@ -10,14 +10,17 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { UserRoles } from '@lib/enums/Roles'; import { apiUrl } from '@lib/functions/Api'; +import type { TableFilter } from '@lib/types/Filters'; import type { TableColumn } from '@lib/types/Tables'; import { useTable } from '../../hooks/UseTable'; import { useUserState } from '../../states/UserState'; import { DescriptionColumn, + PartColumn, ProjectCodeColumn, StatusColumn } from '../ColumnRenderers'; +import { IncludeVariantsFilter } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; import RowExpansionIcon from '../RowExpansionIcon'; import SalesOrderAllocationTable from '../sales/SalesOrderAllocationTable'; @@ -36,6 +39,7 @@ export default function PartSalesAllocationsTable({ { accessor: 'order', title: t`Sales Order`, + switchable: false, render: (record: any) => ( + }, + { + accessor: 'part_detail.IPN', + title: t`IPN` + }, ProjectCodeColumn({ accessor: 'order_detail.project_code_detail' }), @@ -59,7 +72,8 @@ export default function PartSalesAllocationsTable({ }), { accessor: 'allocated', - title: t`Required Stock`, + title: t`Allocated Stock`, + switchable: false, render: (record: any) => ( { + return [IncludeVariantsFilter()]; + }, []); + // Control row expansion const rowExpansion: DataTableRowExpansionProps = useMemo(() => { return { @@ -117,11 +135,13 @@ export default function PartSalesAllocationsTable({ minHeight: 200, params: { part: partId, + part_detail: true, order_detail: true, order_outstanding: true }, + tableFilters: tableFilters, enableSearch: false, - enableColumnSwitching: false, + enableColumnSwitching: true, rowExpansion: rowExpansion, rowActions: rowActions }} diff --git a/src/frontend/src/tables/sales/ReturnOrderTable.tsx b/src/frontend/src/tables/sales/ReturnOrderTable.tsx index 244fd1b2a5..c4fd6dac14 100644 --- a/src/frontend/src/tables/sales/ReturnOrderTable.tsx +++ b/src/frontend/src/tables/sales/ReturnOrderTable.tsx @@ -34,6 +34,7 @@ import { CreatedBeforeFilter, CreatedByFilter, HasProjectCodeFilter, + IncludeVariantsFilter, MaxDateFilter, MinDateFilter, OrderStatusFilter, @@ -93,12 +94,7 @@ export function ReturnOrderTable({ ]; if (!!partId) { - filters.push({ - name: 'include_variants', - type: 'boolean', - label: t`Include Variants`, - description: t`Include orders for part variants` - }); + filters.push(IncludeVariantsFilter()); } return filters; diff --git a/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx b/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx index 3b911740a9..2f58770471 100644 --- a/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderAllocationTable.tsx @@ -32,7 +32,7 @@ import { ReferenceColumn, StatusColumn } from '../ColumnRenderers'; -import { StockLocationFilter } from '../Filter'; +import { IncludeVariantsFilter, StockLocationFilter } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; export default function SalesOrderAllocationTable({ @@ -94,12 +94,7 @@ export default function SalesOrderAllocationTable({ ]; if (!!partId) { - filters.push({ - name: 'include_variants', - type: 'boolean', - label: t`Include Variants`, - description: t`Include orders for part variants` - }); + filters.push(IncludeVariantsFilter()); } return filters; diff --git a/src/frontend/src/tables/sales/SalesOrderTable.tsx b/src/frontend/src/tables/sales/SalesOrderTable.tsx index f82c6eb113..1aa2bfa6cf 100644 --- a/src/frontend/src/tables/sales/SalesOrderTable.tsx +++ b/src/frontend/src/tables/sales/SalesOrderTable.tsx @@ -35,6 +35,7 @@ import { CreatedBeforeFilter, CreatedByFilter, HasProjectCodeFilter, + IncludeVariantsFilter, MaxDateFilter, MinDateFilter, OrderStatusFilter, @@ -94,12 +95,7 @@ export function SalesOrderTable({ ]; if (!!partId) { - filters.push({ - name: 'include_variants', - type: 'boolean', - label: t`Include Variants`, - description: t`Include orders for part variants` - }); + filters.push(IncludeVariantsFilter()); } return filters; diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx index 998a0c909c..816f24bab1 100644 --- a/src/frontend/src/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/tables/stock/StockItemTable.tsx @@ -32,6 +32,7 @@ import { import { BatchFilter, HasBatchCodeFilter, + IncludeVariantsFilter, IsSerializedFilter, SerialFilter, SerialGTEFilter, @@ -356,11 +357,7 @@ function stockItemTableFilters({ label: t`In Production`, description: t`Show items which are in production` }, - { - name: 'include_variants', - label: t`Include Variants`, - description: t`Include stock items for variant parts` - }, + IncludeVariantsFilter(), { name: 'consumed', label: t`Consumed`, diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index fa2998aa19..8004a1ea8e 100644 --- a/src/frontend/tests/pages/pui_part.spec.ts +++ b/src/frontend/tests/pages/pui_part.spec.ts @@ -187,6 +187,45 @@ test('Parts - Details', async ({ browser }) => { await page.getByText('2022-04-29').waitFor(); }); +test('Parts - Requirements', async ({ browser }) => { + // Navigate to the "Widget Assembly" part detail page + // This part has multiple "variants" + // We expect that the template page includes variant requirements + const page = await doCachedLogin(browser, { url: 'part/77/details' }); + + // Check top-level badges + await page.getByText('In Stock: 209').waitFor(); + await page.getByText('Available: 204').waitFor(); + await page.getByText('Required: 275').waitFor(); + await page.getByText('In Production: 24').waitFor(); + + // Check requirements details + await page.getByText('204 / 209').waitFor(); // Available stock + await page.getByText('0 / 100').waitFor(); // Allocated to build orders + await page.getByText('5 / 175').waitFor(); // Allocated to sales orders + await page.getByText('24 / 214').waitFor(); // In production + + // Let's check out the "variants" for this part, too + await navigate(page, 'part/81/details'); // WID-REV-A + await page.getByText('WID-REV-A', { exact: true }).first().waitFor(); + await page.getByText('In Stock: 165').waitFor(); + await page.getByText('Required: 75').waitFor(); + + await navigate(page, 'part/903/details'); // WID-REV-B + await page.getByText('WID-REV-B', { exact: true }).first().waitFor(); + + await page.getByText('In Stock: 44').waitFor(); + await page.getByText('Available: 39').waitFor(); + await page.getByText('Required: 100').waitFor(); + await page.getByText('In Production: 10').waitFor(); + + await page.getByText('39 / 44').waitFor(); // Available stock + await page.getByText('5 / 100').waitFor(); // Allocated to sales orders + await page.getByText('10 / 125').waitFor(); // In production + + await page.waitForTimeout(2500); +}); + test('Parts - Allocations', async ({ browser }) => { // Let's look at the allocations for a single stock item const page = await doCachedLogin(browser, { url: 'stock/item/324/' }); diff --git a/tasks.py b/tasks.py index aa885a66c3..3770d67722 100644 --- a/tasks.py +++ b/tasks.py @@ -296,6 +296,7 @@ def content_excludes( 'exchange.rate', 'exchange.exchangebackend', 'common.dataoutput', + 'common.newsfeedentry', 'common.notificationentry', 'common.notificationmessage', 'importer.dataimportsession',