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(); + } + } + }); +}