diff --git a/CHANGELOG.md b/CHANGELOG.md
index 262dc7a3ca..dcdaffa017 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- [#13208](https://github.com/inventree/InvenTree/pull/13208) Shows the consuming build order in the stock item Child Items table, which includes consumed children by default for users without saved filters.
+
- [#13166](https://github.com/inventree/InvenTree/pull/13166) Adds an "exclude_from_stock" attribute to the StockLocation model, allowing stock items in specified locations to be excluded from stock availability calculations.
### Changed
diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index d90f640c26..8dc45cae32 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
-INVENTREE_API_VERSION = 574
+INVENTREE_API_VERSION = 575
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
+v575 -> 2026-11-10 : https://github.com/inventree/InvenTree/pull/13208
+ - Adds optional read-only "consumed_by_detail" to the StockItem API
+
v574 -> 2026-11-07 : https://github.com/inventree/InvenTree/pull/13169
- breaking: Basic authentication can now be disabled - controlled with INVENTREE_BASIC_AUTH_ENABLED
diff --git a/src/backend/InvenTree/build/serializers_brief.py b/src/backend/InvenTree/build/serializers_brief.py
new file mode 100644
index 0000000000..e7d7f15281
--- /dev/null
+++ b/src/backend/InvenTree/build/serializers_brief.py
@@ -0,0 +1,19 @@
+"""Build summaries for related objects without importing stock serializers."""
+
+from generic.states.fields import InvenTreeCustomStatusSerializerMixin
+from InvenTree.serializers import InvenTreeModelSerializer
+
+from .models import Build
+
+
+class BuildBriefSerializer(
+ InvenTreeCustomStatusSerializerMixin, InvenTreeModelSerializer
+):
+ """Serialize build identity and status for related-object detail fields."""
+
+ class Meta:
+ """Serializer options."""
+
+ model = Build
+ fields = ['pk', 'reference', 'title', 'status', 'status_custom_key']
+ read_only_fields = fields
diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py
index 1ca0cb72fa..804df47e68 100644
--- a/src/backend/InvenTree/stock/api.py
+++ b/src/backend/InvenTree/stock/api.py
@@ -1113,6 +1113,7 @@ class StockOutputOptions(OutputConfiguration):
InvenTreeOutputOption('supplier_part_detail'),
InvenTreeOutputOption('location_detail'),
InvenTreeOutputOption('tests'),
+ InvenTreeOutputOption('consumed_by_detail'),
]
diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py
index bd862fdb25..77250e553a 100644
--- a/src/backend/InvenTree/stock/serializers.py
+++ b/src/backend/InvenTree/stock/serializers.py
@@ -29,6 +29,7 @@ import part.serializers as part_serializers
import stock.filters
import stock.status_codes
from audit.helpers import audit_event, write_batched
+from build.serializers_brief import BuildBriefSerializer
from common.settings import get_global_setting
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
from importer.registry import register_importer
@@ -325,6 +326,7 @@ class StockItemSerializer(
SKIP_CREATE_FIELDS = ['duplicate']
export_child_fields = [
+ 'consumed_by_detail.reference',
'part_detail.name',
'part_detail.description',
'part_detail.IPN',
@@ -353,6 +355,7 @@ class StockItemSerializer(
'belongs_to',
'build',
'consumed_by',
+ 'consumed_by_detail',
'customer',
'delete_on_deplete',
'expiry_date',
@@ -686,6 +689,18 @@ class StockItemSerializer(
help_text=_('Purchase currency of this stock item')
)
+ consumed_by_detail = OptionalField(
+ serializer_class=BuildBriefSerializer,
+ serializer_kwargs={
+ 'label': _('Consumed By'),
+ 'source': 'consumed_by',
+ 'read_only': True,
+ 'allow_null': True,
+ },
+ default_include=False,
+ prefetch_fields=['consumed_by'],
+ )
+
purchase_order_reference = serializers.CharField(
source='purchase_order.reference', read_only=True, allow_null=True
)
diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py
index f7c36aca2b..80aef67332 100644
--- a/src/backend/InvenTree/stock/test_api.py
+++ b/src/backend/InvenTree/stock/test_api.py
@@ -1260,15 +1260,147 @@ class StockItemListTest(StockAPITestCase):
self.assertIn(stock_1.pk, ids)
self.assertIn(stock_2.pk, ids)
+ def test_consumed_by_detail(self):
+ """Consuming build details are optional, nullable, and read-only."""
+ self.assignRole('build.view')
+ assembly = Part.objects.create(name='Reference assembly', assembly=True)
+ part = Part.objects.first()
+ parent = StockItem.objects.create(part=part, quantity=10)
+ builds = [
+ build.models.Build.objects.create(
+ part=assembly, quantity=1, title=f'Consuming build {index}'
+ )
+ for index in range(2)
+ ]
+ consuming_builds = [*builds, None]
+ children = [
+ StockItem.objects.create(
+ part=part, quantity=1, parent=parent, consumed_by=build_order
+ )
+ for build_order in consuming_builds
+ ]
+ params = {'parent': parent.pk}
+ for options in [params, {**params, 'consumed_by_detail': False}]:
+ for record in self.get(self.list_url, options).data:
+ self.assertNotIn('consumed_by_detail', record)
+ self.assertNotIn('consumed_by_reference', record)
+
+ response = self.get(self.list_url, {**params, 'consumed_by_detail': True})
+ records = {item['pk']: item for item in response.data}
+ self.assertEqual(set(records), {item.pk for item in children})
+ for item, build_order in zip(children, consuming_builds, strict=True):
+ detail = records[item.pk]['consumed_by_detail']
+ self.assertEqual(
+ records[item.pk]['consumed_by'], build_order.pk if build_order else None
+ )
+ if build_order:
+ self.assertEqual(detail['pk'], build_order.pk)
+ self.assertEqual(detail['reference'], build_order.reference)
+ self.assertEqual(detail['title'], build_order.title)
+ self.assertEqual(detail['status'], build_order.status)
+ else:
+ self.assertIsNone(detail)
+
+ detail_url = reverse('api-stock-detail', kwargs={'pk': children[0].pk})
+ self.assertNotIn('consumed_by_detail', self.get(detail_url).data)
+ detail = self.get(detail_url, {'consumed_by_detail': True}).data
+ self.assertEqual(detail['consumed_by_detail']['reference'], builds[0].reference)
+
+ original_reference = builds[0].reference
+ self.patch(
+ detail_url, {'consumed_by_detail': {'reference': 'Cannot overwrite'}}
+ )
+ builds[0].refresh_from_db()
+ self.assertEqual(builds[0].reference, original_reference)
+ self.assertEqual(
+ StockItem.objects.get(pk=children[0].pk).consumed_by_id, builds[0].pk
+ )
+
+ # Requesting the field must not bypass build view permissions.
+ self.clearRoles()
+ self.assignRole('stock.view')
+ response = self.get(self.list_url, {**params, 'consumed_by_detail': True})
+ for record in response.data:
+ self.assertNotIn('consumed_by_detail', record)
+ self.assertNotIn(
+ 'consumed_by_detail',
+ self.get(detail_url, {'consumed_by_detail': True}).data,
+ )
+
+ def test_consumed_by_detail_export(self):
+ """Export consuming references only when requested and permitted."""
+ self.assignRole('build.view')
+ assembly = Part.objects.create(name='Export assembly', assembly=True)
+ build_order = build.models.Build.objects.create(part=assembly, quantity=1)
+ part = Part.objects.first()
+ parent = StockItem.objects.create(part=part, quantity=10)
+ consumed = StockItem.objects.create(
+ part=part, quantity=1, parent=parent, consumed_by=build_order
+ )
+ unconsumed = StockItem.objects.create(part=part, quantity=1, parent=parent)
+ filters = {'parent': parent.pk}
+ params = {**filters, 'consumed_by_detail': True}
+ header = 'Consumed By.Reference'
+
+ with self.export_data(self.list_url, params=params) as data_file:
+ rows = self.process_csv(data_file, required_cols=[header], required_rows=2)
+ records = {int(row['ID']): row for row in rows}
+ self.assertEqual(records[consumed.pk][header], build_order.reference)
+ self.assertEqual(records[unconsumed.pk][header], '')
+
+ for options in [filters, {**filters, 'consumed_by_detail': False}]:
+ with self.export_data(self.list_url, params=options) as data_file:
+ self.process_csv(data_file, excluded_cols=[header], required_rows=2)
+
+ self.clearRoles()
+ self.assignRole('stock.view')
+ with self.export_data(self.list_url, params=params) as data_file:
+ self.process_csv(data_file, excluded_cols=[header], required_rows=2)
+
+ def test_consumed_by_detail_prefetch(self):
+ """Optional details prefetch builds even without StockApiMixin annotations."""
+ from stock.serializers import StockItemSerializer
+
+ assembly = Part.objects.create(name='Prefetch assembly', assembly=True)
+ builds = [
+ build.models.Build.objects.create(part=assembly, quantity=1)
+ for _ in range(10)
+ ]
+ StockItem.objects.bulk_create([
+ StockItem(part=assembly, quantity=1, consumed_by=build_order)
+ for build_order in builds
+ ])
+ queryset = StockItem.objects.filter(consumed_by__in=builds)
+ serializer = StockItemSerializer(consumed_by_detail=True)
+ self.assertIn('consumed_by', serializer.prefetch_list)
+ self.assertNotIn('consumed_by', StockItemSerializer().prefetch_list)
+ with self.assertNumQueries(2):
+ prefetched = list(serializer.prefetch_queryset(queryset))
+ references = [item.consumed_by.reference for item in prefetched]
+ self.assertCountEqual(
+ references, [build_order.reference for build_order in builds]
+ )
+
def test_query_count(self):
"""Test that the number of queries required to fetch stock items is reasonable."""
# Create a bunch of StockItem objects
prt = Part.objects.first()
+ assembly = Part.objects.create(name='Query assembly', assembly=True)
+ builds = [
+ build.models.Build.objects.create(part=assembly, quantity=1)
+ for _ in range(10)
+ ]
+ # Include consuming builds so missing prefetches exceed the query budget.
StockItem.objects.bulk_create([
- StockItem(part=prt, quantity=1) for _ in range(100)
+ StockItem(part=prt, quantity=1, consumed_by=bo)
+ for bo in builds
+ for _ in range(10)
])
+ self.assignRole('build.view')
+ self.get(self.list_url, {'consumed_by_detail': True}, max_query_count=35)
+
# List *all* stock items
self.get(self.list_url, {}, max_query_count=35)
@@ -2085,6 +2217,7 @@ class StockItemTest(StockAPITestCase):
def test_output_options(self):
"""Test the output options for StockItemt detail."""
+ self.assignRole('build.view')
self.run_output_test(
reverse('api-stock-detail', kwargs={'pk': 1}),
[
@@ -2093,6 +2226,7 @@ class StockItemTest(StockAPITestCase):
'supplier_part_detail',
'location_detail',
'tests',
+ 'consumed_by_detail',
],
)
diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx
index 27a33e57cc..c401c4afef 100644
--- a/src/frontend/src/pages/stock/StockDetail.tsx
+++ b/src/frontend/src/pages/stock/StockDetail.tsx
@@ -298,6 +298,8 @@ export default function StockDetail() {
content: stockitem?.pk ? (
) : (
diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx
index ce91cce8ee..86e07a3265 100644
--- a/src/frontend/src/tables/stock/StockItemTable.tsx
+++ b/src/frontend/src/tables/stock/StockItemTable.tsx
@@ -1,5 +1,6 @@
import { t } from '@lingui/core/macro';
import { useMemo, useState } from 'react';
+import { type NavigateFunction, useNavigate } from 'react-router-dom';
import { ActionButton } from '@lib/components/ActionButton';
import { AddItemButton } from '@lib/components/AddItemButton';
@@ -7,10 +8,12 @@ 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 { getDetailUrl } from '@lib/functions/Navigation';
import useTable from '@lib/hooks/UseTable';
import type { TableFilter } from '@lib/types/Filters';
import type { StockOperationProps } from '@lib/types/Forms';
import type { TableColumn } from '@lib/types/Tables';
+import { RenderInlineModel } from '../../components/render/Instance';
import {
DateColumn,
DescriptionColumn,
@@ -53,10 +56,14 @@ import { useUserState } from '../../states/UserState';
*/
function stockItemTableColumns({
showLocation,
- showPricing
+ showPricing,
+ showConsumedBy,
+ navigate
}: {
showLocation: boolean;
showPricing: boolean;
+ showConsumedBy: boolean;
+ navigate: NavigateFunction;
}): TableColumn[] {
return [
PartColumn({
@@ -89,6 +96,20 @@ function stockItemTableColumns({
]
}),
StatusColumn({ model: ModelType.stockitem }),
+ {
+ accessor: 'consumed_by_detail.reference',
+ title: t`Consumed By`,
+ sortable: false,
+ hidden: !showConsumedBy,
+ render: (record: any) =>
+ record.consumed_by_detail ? (
+
+ ) : null
+ },
{
accessor: 'batch',
sortable: true,
@@ -337,6 +358,7 @@ export function StockItemTable({
allowAdd = false,
showLocation = true,
showPricing = true,
+ showConsumedBy = false,
allowReturn = false,
initialFilters,
defaultInStock = true,
@@ -346,6 +368,7 @@ export function StockItemTable({
allowAdd?: boolean;
showLocation?: boolean;
showPricing?: boolean;
+ showConsumedBy?: boolean;
allowReturn?: boolean;
defaultInStock?: boolean | null;
initialFilters?: TableFilter[];
@@ -376,6 +399,7 @@ export function StockItemTable({
});
const user = useUserState();
+ const navigate = useNavigate();
const settings = useGlobalSettingsState();
@@ -388,9 +412,11 @@ export function StockItemTable({
() =>
stockItemTableColumns({
showLocation: showLocation ?? true,
- showPricing: showPricing ?? true
+ showPricing: showPricing ?? true,
+ showConsumedBy,
+ navigate
}),
- [showLocation, showPricing]
+ [showLocation, showPricing, showConsumedBy, navigate]
);
const tableFilters: TableFilter[] = useMemo(
@@ -500,6 +526,7 @@ export function StockItemTable({
modelType: ModelType.stockitem,
params: {
...params,
+ ...(showConsumedBy ? { consumed_by_detail: true } : {}),
part_detail: true,
location_detail: true,
supplier_part_detail: true
diff --git a/src/frontend/tests/pages/pui_stock_consumed.spec.ts b/src/frontend/tests/pages/pui_stock_consumed.spec.ts
new file mode 100644
index 0000000000..f84d57e031
--- /dev/null
+++ b/src/frontend/tests/pages/pui_stock_consumed.spec.ts
@@ -0,0 +1,236 @@
+import type { APIResponse } from '@playwright/test';
+
+import { createApi } from '../api.js';
+import { expect, generateUUID, test } from '../baseFixtures.js';
+import { clearTableFilters, loadTab, navigate } from '../helpers.js';
+import { doCachedLogin } from '../login.js';
+
+for (const savedFilter of [false, true]) {
+ test(`Stock - Consuming builds${savedFilter ? ' with saved filter' : ''}`, async ({
+ browser
+ }) => {
+ const api = await createApi({});
+ const stockIds: number[] = [];
+ const buildIds: number[] = [];
+ let locationId: number | undefined;
+ let partId: number | undefined;
+
+ const create = async (url: string, data: object) => {
+ const response = await api.post(url, { data });
+ expect(response.status(), await response.text()).toBe(201);
+ const body = await response.json();
+ return Array.isArray(body) ? body[0] : body;
+ };
+
+ try {
+ const part = await create('part/', {
+ name: `Consumed reference ${generateUUID().slice(0, 8)}`,
+ assembly: true,
+ component: true
+ });
+ partId = part.pk;
+ const parent = await create('stock/', { part: partId, quantity: 10 });
+ stockIds.push(parent.pk);
+
+ const builds = [];
+ for (let index = 0; index < 2; index++) {
+ const build = await create('build/', {
+ part: partId,
+ quantity: 1,
+ title: `Reference test ${index}`
+ });
+ builds.push(build);
+ buildIds.push(build.pk);
+ }
+
+ const location = await create('stock/location/', {
+ name: `Reference location ${generateUUID().slice(0, 8)}`
+ });
+ locationId = location.pk;
+
+ // Partial transfers create children; the parent field is read-only.
+ const children = [];
+ for (const build of [...builds, null]) {
+ const response = await api.post('stock/transfer/', {
+ data: {
+ location: locationId,
+ items: [{ pk: parent.pk, quantity: 1 }]
+ }
+ });
+ expect(response.ok(), await response.text()).toBeTruthy();
+ const records = await (
+ await api.get('stock/', { params: { parent: parent.pk } })
+ ).json();
+ const child = records.find(
+ (record: { pk: number }) => !stockIds.includes(record.pk)
+ );
+ expect(child).toBeTruthy();
+ stockIds.push(child.pk);
+ const patch = await api.patch(`stock/${child.pk}/`, {
+ data: {
+ batch: `Reference-${generateUUID().slice(0, 8)}`,
+ consumed_by: build?.pk ?? null
+ }
+ });
+ expect(patch.ok(), await patch.text()).toBeTruthy();
+ children.push(await patch.json());
+ }
+
+ const unconsumedChild = children[builds.length];
+ const page = await doCachedLogin(browser);
+ const childRequests: URL[] = [];
+ page.on('request', (request) => {
+ const url = new URL(request.url());
+ if (
+ request.method() === 'GET' &&
+ url.pathname === '/api/stock/' &&
+ url.searchParams.get('parent') === String(parent.pk)
+ ) {
+ childRequests.push(url);
+ }
+ });
+
+ // Cover fresh table preferences and a previously saved in-stock filter.
+ await page.evaluate((saved) => {
+ localStorage.removeItem('inventree-filterset-table-child-stock');
+ if (saved)
+ localStorage.setItem(
+ 'inventree-filterset-table-child-stock',
+ JSON.stringify([{ name: 'in_stock', value: 'true' }])
+ );
+ }, savedFilter);
+ await navigate(page, `stock/item/${parent.pk}/child-items`);
+ await loadTab(page, 'Child Items');
+ const header = page.getByRole('columnheader', {
+ name: 'Consumed By',
+ exact: true
+ });
+ await expect(header).toBeVisible();
+
+ if (savedFilter) {
+ await expect(
+ page.getByRole('cell', { name: unconsumedChild.batch, exact: true })
+ ).toBeVisible();
+ await expect(
+ page.getByRole('link', { name: builds[0].reference, exact: true })
+ ).toHaveCount(0);
+ await page.reload();
+ await expect(
+ page.getByRole('cell', { name: unconsumedChild.batch, exact: true })
+ ).toBeVisible();
+ await expect(
+ page.getByRole('link', { name: builds[0].reference, exact: true })
+ ).toHaveCount(0);
+ await clearTableFilters(page);
+ }
+
+ for (const [index, build] of builds.entries()) {
+ const row = page.getByRole('row').filter({
+ has: page.getByRole('cell', {
+ name: children[index].batch,
+ exact: true
+ })
+ });
+ await expect(row).toBeVisible();
+ await expect(
+ row.getByRole('link', { name: build.reference, exact: true })
+ ).toHaveAttribute(
+ 'href',
+ `/web/manufacturing/build-order/${build.pk}/`
+ );
+ }
+
+ expect(childRequests.length).toBeGreaterThan(0);
+ expect(
+ childRequests.every(
+ (url) => url.searchParams.get('consumed_by_detail') === 'true'
+ )
+ ).toBeTruthy();
+
+ const unconsumed = page.getByRole('row').filter({
+ has: page.getByRole('cell', {
+ name: unconsumedChild.batch,
+ exact: true
+ })
+ });
+ await expect(unconsumed).toBeVisible();
+ const columnIndex = await header.evaluate((element) =>
+ Array.from(element.parentElement!.children).indexOf(element)
+ );
+ await expect(unconsumed.getByRole('cell').nth(columnIndex)).toBeEmpty();
+
+ if (!savedFilter) {
+ await page.getByLabel('table-select-columns').click();
+ await page
+ .getByRole('checkbox', { name: 'Consumed By', exact: true })
+ .uncheck();
+ await page.keyboard.press('Escape');
+ await page.reload();
+ await expect(header).toHaveCount(0);
+ await page.getByLabel('table-select-columns').click();
+ await page
+ .getByRole('checkbox', { name: 'Consumed By', exact: true })
+ .check();
+ await page.keyboard.press('Escape');
+ await page
+ .getByRole('link', { name: builds[0].reference, exact: true })
+ .click();
+ await expect(page).toHaveURL(
+ new RegExp(`/manufacturing/build-order/${builds[0].pk}`)
+ );
+ await navigate(page, `part/${partId}/stock`);
+ await expect(header).toHaveCount(0);
+ await page.getByLabel('table-select-columns').click();
+ await expect(
+ page.getByRole('checkbox', { name: 'Consumed By', exact: true })
+ ).toHaveCount(0);
+ }
+ } finally {
+ try {
+ const cleanupFailures: string[] = [];
+ const cleanup = async (response: APIResponse) => {
+ if (!response.ok()) cleanupFailures.push(await response.text());
+ };
+
+ for (const pk of stockIds.reverse())
+ await cleanup(await api.delete(`stock/${pk}/`));
+ for (const pk of buildIds) {
+ await cleanup(
+ await api.post(`build/${pk}/cancel/`, {
+ data: {
+ remove_allocated_stock: false,
+ remove_incomplete_outputs: true
+ }
+ })
+ );
+ // Cancellation runs in the worker; wait for its committed state.
+ await expect
+ .poll(
+ async () => (await (await api.get(`build/${pk}/`)).json()).status,
+ { timeout: 15000 }
+ )
+ .toBe(30);
+ await cleanup(await api.delete(`build/${pk}/`));
+ }
+
+ if (partId) {
+ await cleanup(
+ await api.patch(`part/${partId}/`, { data: { active: false } })
+ );
+ await cleanup(await api.delete(`part/${partId}/`));
+ }
+
+ if (locationId)
+ await cleanup(
+ await api.delete(`stock/location/${locationId}/`, {
+ data: { delete_stock_items: false, delete_sub_locations: false }
+ })
+ );
+
+ expect(cleanupFailures).toEqual([]);
+ } finally {
+ await api.dispose();
+ }
+ }
+ });
+}