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',