diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index c9f3d2c652..b9a465f21a 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 = 451
+INVENTREE_API_VERSION = 452
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
+v452 -> 2026-02-10 : https://github.com/inventree/InvenTree/pull/11276
+ - Adds "install_into_detail" field to the BuildItem API endpoint
+
v451 -> 2026-02-10 : https://github.com/inventree/InvenTree/pull/11277
- Adds sorting to multiple part related endpoints (part, IPN, ...)
diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py
index 4ecf9c12b7..b155602b50 100644
--- a/src/backend/InvenTree/build/api.py
+++ b/src/backend/InvenTree/build/api.py
@@ -808,13 +808,17 @@ class BuildCancel(BuildOrderContextMixin, CreateAPI):
serializer_class = build.serializers.BuildCancelSerializer
-class BuildItemDetail(RetrieveUpdateDestroyAPI):
- """API endpoint for detail view of a BuildItem object."""
+class BuildItemMixin:
+ """Mixin class for BuildItem API endpoints."""
- queryset = BuildItem.objects.all()
+ queryset = BuildItem.objects.all().prefetch_related('stock_item__location')
serializer_class = build.serializers.BuildItemSerializer
+class BuildItemDetail(BuildItemMixin, RetrieveUpdateDestroyAPI):
+ """API endpoint for detail view of a BuildItem object."""
+
+
class BuildItemFilter(FilterSet):
"""Custom filterset for the BuildItemList API endpoint."""
@@ -899,16 +903,45 @@ class BuildItemOutputOptions(OutputConfiguration):
"""Output options for BuildItem endpoint."""
OPTIONS = [
- InvenTreeOutputOption('part_detail'),
- InvenTreeOutputOption('location_detail'),
- InvenTreeOutputOption('stock_detail'),
- InvenTreeOutputOption('build_detail'),
- InvenTreeOutputOption('supplier_part_detail'),
+ InvenTreeOutputOption(
+ 'part_detail',
+ default=False,
+ description='Include detailed information about the part associated with this build item.',
+ ),
+ InvenTreeOutputOption(
+ 'location_detail',
+ default=False,
+ description='Include detailed information about the location of the allocated stock item.',
+ ),
+ InvenTreeOutputOption(
+ 'stock_detail',
+ default=False,
+ description='Include detailed information about the allocated stock item.',
+ ),
+ InvenTreeOutputOption(
+ 'build_detail',
+ default=False,
+ description='Include detailed information about the associated build order.',
+ ),
+ InvenTreeOutputOption(
+ 'supplier_part_detail',
+ default=False,
+ description='Include detailed information about the supplier part associated with this build item.',
+ ),
+ InvenTreeOutputOption(
+ 'install_into_detail',
+ default=False,
+ description='Include detailed information about the build output for this build item.',
+ ),
]
class BuildItemList(
- DataExportViewMixin, OutputOptionsMixin, BulkDeleteMixin, ListCreateAPI
+ BuildItemMixin,
+ DataExportViewMixin,
+ OutputOptionsMixin,
+ BulkDeleteMixin,
+ ListCreateAPI,
):
"""API endpoint for accessing a list of BuildItem objects.
@@ -917,8 +950,6 @@ class BuildItemList(
"""
output_options = BuildItemOutputOptions
- queryset = BuildItem.objects.all()
- serializer_class = build.serializers.BuildItemSerializer
filterset_class = BuildItemFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS
diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py
index 641fd2eb12..48f290f2d3 100644
--- a/src/backend/InvenTree/build/serializers.py
+++ b/src/backend/InvenTree/build/serializers.py
@@ -1179,6 +1179,7 @@ class BuildItemSerializer(
'part_detail',
'stock_item_detail',
'supplier_part_detail',
+ 'install_into_detail',
# The following fields are only used for data export
'bom_reference',
'bom_part_id',
@@ -1244,6 +1245,21 @@ class BuildItemSerializer(
],
)
+ install_into_detail = enable_filter(
+ StockItemSerializer(
+ source='install_into',
+ read_only=True,
+ allow_null=True,
+ label=_('Install Into'),
+ part_detail=False,
+ location_detail=False,
+ supplier_part_detail=False,
+ path_detail=False,
+ ),
+ False,
+ prefetch_fields=['install_into', 'install_into__part'],
+ )
+
location = serializers.PrimaryKeyRelatedField(
label=_('Location'), source='stock_item.location', many=False, read_only=True
)
diff --git a/src/frontend/lib/functions/Conversion.tsx b/src/frontend/lib/functions/Conversion.tsx
index dcc6409104..915f9b153b 100644
--- a/src/frontend/lib/functions/Conversion.tsx
+++ b/src/frontend/lib/functions/Conversion.tsx
@@ -29,6 +29,11 @@ export function isTrue(value: any): boolean {
* Allows for retrieval of nested items in an object.
*/
export function resolveItem(obj: any, path: string): any {
+ // Return the top-level object if no path is provided
+ if (path == null || path === '') {
+ return obj;
+ }
+
const properties = path.split('.');
return properties.reduce((prev, curr) => prev?.[curr], obj);
}
diff --git a/src/frontend/src/tables/ColumnRenderers.tsx b/src/frontend/src/tables/ColumnRenderers.tsx
index b38c125fd8..966cf004a2 100644
--- a/src/frontend/src/tables/ColumnRenderers.tsx
+++ b/src/frontend/src/tables/ColumnRenderers.tsx
@@ -93,10 +93,10 @@ export function PartColumn(props: PartColumnProps): TableColumn {
switchable: false,
minWidth: '175px',
render: (record: any) => {
- const part =
- props.part === ''
- ? record
- : resolveItem(record, props.part ?? props.accessor ?? 'part_detail');
+ const part = resolveItem(
+ record,
+ props.part ?? props.accessor ?? 'part_detail'
+ );
return RenderPartColumn({
part: part,
@@ -107,6 +107,174 @@ export function PartColumn(props: PartColumnProps): TableColumn {
};
}
+export type StockColumnProps = TableColumnProps & {
+ nullMessage?: string | ReactNode;
+};
+
+// Render a StockItem instance within a table
+export function StockColumn(props: StockColumnProps): TableColumn {
+ return {
+ accessor: props.accessor ?? 'stock_item',
+ title: t`Stock Item`,
+ ...props,
+ render: (record: any) => {
+ const stock_item =
+ resolveItem(record, props.accessor ?? 'stock_item_detail') ?? {};
+ const part = stock_item.part_detail ?? {};
+
+ const quantity = stock_item.quantity ?? 0;
+ const allocated = stock_item.allocated ?? 0;
+ const available = quantity - allocated;
+
+ const extra: ReactNode[] = [];
+ let color = undefined;
+ let text = formatDecimal(quantity);
+
+ // Handle case where stock item detail is not provided
+ if (!stock_item || !stock_item.pk) {
+ return props.nullMessage ?? '-';
+ }
+
+ // Override with serial number if available
+ if (stock_item.serial && quantity == 1) {
+ text = `# ${stock_item.serial}`;
+ }
+
+ if (record.is_building) {
+ color = 'blue';
+ extra.push(
+ {t`This stock item is in production`}
+ );
+ } else if (record.sales_order) {
+ extra.push(
+ {t`This stock item has been assigned to a sales order`}
+ );
+ } else if (record.customer) {
+ extra.push(
+ {t`This stock item has been assigned to a customer`}
+ );
+ } else if (record.belongs_to) {
+ extra.push(
+ {t`This stock item is installed in another stock item`}
+ );
+ } else if (record.consumed_by) {
+ extra.push(
+ {t`This stock item has been consumed by a build order`}
+ );
+ } else if (!record.in_stock) {
+ extra.push(
+ {t`This stock item is unavailable`}
+ );
+ }
+
+ if (record.expired) {
+ extra.push(
+ {t`This stock item has expired`}
+ );
+ } else if (record.stale) {
+ extra.push(
+ {t`This stock item is stale`}
+ );
+ }
+
+ if (record.in_stock) {
+ if (allocated > 0) {
+ if (allocated > quantity) {
+ color = 'red';
+ extra.push(
+ {t`This stock item is over-allocated`}
+ );
+ } else if (allocated == quantity) {
+ color = 'orange';
+ extra.push(
+ {t`This stock item is fully allocated`}
+ );
+ } else {
+ extra.push(
+ {t`This stock item is partially allocated`}
+ );
+ }
+ }
+
+ if (available != quantity) {
+ if (available > 0) {
+ extra.push(
+
+ {`${t`Available`}: ${formatDecimal(available)}`}
+
+ );
+ } else {
+ extra.push(
+ {t`No stock available`}
+ );
+ }
+ }
+
+ if (quantity <= 0) {
+ extra.push(
+ {t`This stock item has been depleted`}
+ );
+ }
+ }
+
+ if (!record.in_stock) {
+ color = 'red';
+ }
+
+ return (
+
+ {text}
+ {part.units && (
+
+ [{part.units}]
+
+ )}
+
+ }
+ title={t`Stock Information`}
+ extra={extra}
+ />
+ );
+ }
+ };
+}
+
export function CompanyColumn({
company
}: {
diff --git a/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx b/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx
index b09ade00e3..acc0bfe67d 100644
--- a/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx
+++ b/src/frontend/src/tables/build/BuildAllocatedStockTable.tsx
@@ -25,7 +25,8 @@ import {
LocationColumn,
PartColumn,
ReferenceColumn,
- StatusColumn
+ StatusColumn,
+ StockColumn
} from '../ColumnRenderers';
import { IncludeVariantsFilter, StockLocationFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
@@ -142,11 +143,11 @@ export default function BuildAllocatedStockTable({
switchable: true,
sortable: true
}),
- {
- accessor: 'install_into',
+ StockColumn({
+ accessor: 'install_into_detail',
title: t`Build Output`,
- sortable: true
- },
+ sortable: false
+ }),
{
accessor: 'sku',
title: t`Supplier Part`,
@@ -307,6 +308,7 @@ export default function BuildAllocatedStockTable({
part_detail: showPartInfo ?? false,
location_detail: true,
stock_detail: true,
+ install_into_detail: true,
supplier_detail: true
},
enableBulkDelete: allowEdit && user.hasDeleteRole(UserRoles.build),
diff --git a/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx b/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx
index 0f7684725d..456afb5afc 100644
--- a/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx
+++ b/src/frontend/src/tables/sales/ReturnOrderLineItemTable.tsx
@@ -36,7 +36,8 @@ import {
PartColumn,
ProjectCodeColumn,
ReferenceColumn,
- StatusColumn
+ StatusColumn,
+ StockColumn
} from '../ColumnRenderers';
import { StatusFilterOptions } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
@@ -121,20 +122,12 @@ export default function ReturnOrderLineItemTable({
DescriptionColumn({
accessor: 'part_detail.description'
}),
- {
- accessor: 'item_detail.serial',
- title: t`Quantity`,
+ StockColumn({
+ accessor: 'item_detail',
switchable: false,
sortable: true,
- ordering: 'stock',
- render: (record: any) => {
- if (record.item_detail.serial && record.quantity == 1) {
- return `# ${record.item_detail.serial}`;
- } else {
- return record.quantity;
- }
- }
- },
+ ordering: 'stock'
+ }),
StatusColumn({
model: ModelType.stockitem,
sortable: false,
diff --git a/src/frontend/src/tables/stock/InstalledItemsTable.tsx b/src/frontend/src/tables/stock/InstalledItemsTable.tsx
index 4f3678e8c1..3de11d245c 100644
--- a/src/frontend/src/tables/stock/InstalledItemsTable.tsx
+++ b/src/frontend/src/tables/stock/InstalledItemsTable.tsx
@@ -8,7 +8,6 @@ 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 { formatDecimal } from '@lib/functions/Formatting';
import type { TableColumn } from '@lib/types/Tables';
import {
useStockItemInstallFields,
@@ -17,7 +16,7 @@ import {
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { useUserState } from '../../states/UserState';
-import { PartColumn, StatusColumn } from '../ColumnRenderers';
+import { PartColumn, StatusColumn, StockColumn } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
export default function InstalledItemsTable({
@@ -62,19 +61,11 @@ export default function InstalledItemsTable({
PartColumn({
part: 'part_detail'
}),
- {
- accessor: 'quantity',
- switchable: false,
- render: (record: any) => {
- let text = formatDecimal(record.quantity);
-
- if (record.serial && record.quantity == 1) {
- text = `# ${record.serial}`;
- }
-
- return text;
- }
- },
+ StockColumn({
+ accessor: '',
+ title: t`Stock Item`,
+ sortable: false
+ }),
{
accessor: 'batch',
switchable: false
diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx
index f8204cc71f..26e0245319 100644
--- a/src/frontend/src/tables/stock/StockItemTable.tsx
+++ b/src/frontend/src/tables/stock/StockItemTable.tsx
@@ -1,6 +1,5 @@
import { t } from '@lingui/core/macro';
-import { Group, Text } from '@mantine/core';
-import { type ReactNode, useMemo, useState } from 'react';
+import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ActionButton } from '@lib/components/ActionButton';
@@ -14,11 +13,7 @@ import type { TableFilter } from '@lib/types/Filters';
import type { StockOperationProps } from '@lib/types/Forms';
import type { TableColumn } from '@lib/types/Tables';
import OrderPartsWizard from '../../components/wizards/OrderPartsWizard';
-import {
- formatCurrency,
- formatDecimal,
- formatPriceRange
-} from '../../defaults/formatters';
+import { formatCurrency, formatPriceRange } from '../../defaults/formatters';
import { useStockFields } from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons';
import { useCreateApiFormModal } from '../../hooks/UseForm';
@@ -31,7 +26,8 @@ import {
DescriptionColumn,
LocationColumn,
PartColumn,
- StatusColumn
+ StatusColumn,
+ StockColumn
} from '../ColumnRenderers';
import {
BatchFilter,
@@ -47,7 +43,6 @@ import {
SupplierFilter
} from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
-import { TableHoverCard } from '../TableHoverCard';
/**
* Construct a list of columns for the stock item table
@@ -79,161 +74,12 @@ function stockItemTableColumns({
DescriptionColumn({
accessor: 'part_detail.description'
}),
- {
- accessor: 'quantity',
- ordering: 'stock',
- sortable: true,
+ StockColumn({
+ accessor: '',
title: t`Stock`,
- render: (record: any) => {
- // TODO: Push this out into a custom renderer
- const quantity = record?.quantity ?? 0;
- const allocated = record?.allocated ?? 0;
- const available = quantity - allocated;
- let text = formatDecimal(quantity);
- const part = record?.part_detail ?? {};
- const extra: ReactNode[] = [];
- let color = undefined;
-
- if (record.serial && quantity == 1) {
- text = `# ${record.serial}`;
- }
-
- if (record.is_building) {
- color = 'blue';
- extra.push(
- {t`This stock item is in production`}
- );
- } else if (record.sales_order) {
- extra.push(
- {t`This stock item has been assigned to a sales order`}
- );
- } else if (record.customer) {
- extra.push(
- {t`This stock item has been assigned to a customer`}
- );
- } else if (record.belongs_to) {
- extra.push(
- {t`This stock item is installed in another stock item`}
- );
- } else if (record.consumed_by) {
- extra.push(
- {t`This stock item has been consumed by a build order`}
- );
- } else if (!record.in_stock) {
- extra.push(
- {t`This stock item is unavailable`}
- );
- }
-
- if (record.expired) {
- extra.push(
- {t`This stock item has expired`}
- );
- } else if (record.stale) {
- extra.push(
- {t`This stock item is stale`}
- );
- }
-
- if (record.in_stock) {
- if (allocated > 0) {
- if (allocated > quantity) {
- color = 'red';
- extra.push(
- {t`This stock item is over-allocated`}
- );
- } else if (allocated == quantity) {
- color = 'orange';
- extra.push(
- {t`This stock item is fully allocated`}
- );
- } else {
- extra.push(
- {t`This stock item is partially allocated`}
- );
- }
- }
-
- if (available != quantity) {
- if (available > 0) {
- extra.push(
-
- {`${t`Available`}: ${formatDecimal(available)}`}
-
- );
- } else {
- extra.push(
- {t`No stock available`}
- );
- }
- }
-
- if (quantity <= 0) {
- extra.push(
- {t`This stock item has been depleted`}
- );
- }
- }
-
- if (!record.in_stock) {
- color = 'red';
- }
-
- return (
-
- {text}
- {part.units && (
-
- [{part.units}]
-
- )}
-
- }
- title={t`Stock Information`}
- extra={extra}
- />
- );
- }
- },
+ sortable: true,
+ ordering: 'stock'
+ }),
StatusColumn({ model: ModelType.stockitem }),
{
accessor: 'batch',
diff --git a/src/frontend/src/tables/stock/StockTrackingTable.tsx b/src/frontend/src/tables/stock/StockTrackingTable.tsx
index 0a6f4f1166..c901a23952 100644
--- a/src/frontend/src/tables/stock/StockTrackingTable.tsx
+++ b/src/frontend/src/tables/stock/StockTrackingTable.tsx
@@ -24,7 +24,12 @@ import {
} from '../../components/render/Stock';
import { RenderUser } from '../../components/render/User';
import { useTable } from '../../hooks/UseTable';
-import { DateColumn, DescriptionColumn, PartColumn } from '../ColumnRenderers';
+import {
+ DateColumn,
+ DescriptionColumn,
+ PartColumn,
+ StockColumn
+} from '../ColumnRenderers';
import {
IncludeVariantsFilter,
MaxDateFilter,
@@ -241,29 +246,16 @@ export function StockTrackingTable({
switchable: true,
hidden: !partId
},
- {
- accessor: 'item',
+ StockColumn({
title: t`Stock Item`,
+ accessor: 'item_detail',
+ nullMessage: (
+ {t`Stock item no longer exists`}
+ ),
sortable: false,
switchable: false,
- hidden: !partId,
- render: (record: any) => {
- const item = record.item_detail;
- if (!item) {
- return (
- {t`Stock item no longer exists`}
- );
- } else if (item.serial && item.quantity == 1) {
- return `${t`Serial`} #${item.serial}`;
- } else {
- return `${t`Item ID`} ${item.pk}`;
- }
- }
- },
+ hidden: !partId
+ }),
DescriptionColumn({
accessor: 'label'
}),
diff --git a/src/frontend/tests/pages/pui_stock.spec.ts b/src/frontend/tests/pages/pui_stock.spec.ts
index ec330474e3..7bebed2b00 100644
--- a/src/frontend/tests/pages/pui_stock.spec.ts
+++ b/src/frontend/tests/pages/pui_stock.spec.ts
@@ -470,8 +470,8 @@ test('Stock - Tracking', async ({ browser }) => {
.getByRole('cell', { name: 'Thumbnail Blue Widget' })
.first()
.waitFor();
- await page.getByRole('cell', { name: 'Item ID 232' }).first().waitFor();
- await page.getByRole('cell', { name: 'Serial #116' }).first().waitFor();
+
+ await page.getByText('# 162').first().waitFor();
});
test('Stock - Location', async ({ browser }) => {