[UI] Show consuming build orders in child stock items (#13208)

* Show consuming build orders in child stock items

* Reference PR in changelog and API version notes

* Clarify stock reference test setup and assertions

* Use optional build details for consumed stock

* Include consuming build references in stock exports

* Make stock prefetch test portable to MySQL
This commit is contained in:
Miguel Brandt authored and GitHub committed 2026-11-11 11:27:02 +11:00
1 parent 5e7bf4f92d
commit 6c363d4a32
9 files changed
+444 -5

No files matched your search

+2
View File
@@ -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
@@ -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
@@ -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
+1
View File
@@ -1113,6 +1113,7 @@ class StockOutputOptions(OutputConfiguration):
InvenTreeOutputOption('supplier_part_detail'),
InvenTreeOutputOption('location_detail'),
InvenTreeOutputOption('tests'),
InvenTreeOutputOption('consumed_by_detail'),
]
@@ -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
)
+135 -1
View File
@@ -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',
],
)
@@ -298,6 +298,8 @@ export default function StockDetail() {
content: stockitem?.pk ? (
<StockItemTable
tableName='child-stock'
showConsumedBy
defaultInStock={null}
params={{ parent: stockitem.pk }}
/>
) : (
@@ -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 ? (
<RenderInlineModel
primary={record.consumed_by_detail.reference}
url={getDetailUrl(ModelType.build, record.consumed_by_detail.pk)}
navigate={navigate}
/>
) : 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
@@ -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();
}
}
});
}