2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-02-13 01:38:03 +00:00

Display more output information in "build allocated stock" table (#11276)

* Add "install_into_detail" to BuildItem serializer

* Enhance the "resolveItem" function

* Add "StockColumn" renderer

* Fix output column for BuildAllocatedStockTable

* Replace column in stock item table

* More column refactoring

* Bump API version

* Add InvenTreeOutputOption descriptions

* Prefetch for better API performance

* Updated playwright testing
This commit is contained in:
Oliver
2026-02-11 23:25:56 +11:00
committed by GitHub
parent e963b8219b
commit 3ebf27df36
11 changed files with 282 additions and 235 deletions

View File

@@ -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, ...)

View File

@@ -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

View File

@@ -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
)

View File

@@ -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);
}

View File

@@ -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(
<Text
key='production'
size='sm'
>{t`This stock item is in production`}</Text>
);
} else if (record.sales_order) {
extra.push(
<Text
key='sales-order'
size='sm'
>{t`This stock item has been assigned to a sales order`}</Text>
);
} else if (record.customer) {
extra.push(
<Text
key='customer'
size='sm'
>{t`This stock item has been assigned to a customer`}</Text>
);
} else if (record.belongs_to) {
extra.push(
<Text
key='belongs-to'
size='sm'
>{t`This stock item is installed in another stock item`}</Text>
);
} else if (record.consumed_by) {
extra.push(
<Text
key='consumed-by'
size='sm'
>{t`This stock item has been consumed by a build order`}</Text>
);
} else if (!record.in_stock) {
extra.push(
<Text
key='unavailable'
size='sm'
>{t`This stock item is unavailable`}</Text>
);
}
if (record.expired) {
extra.push(
<Text key='expired' size='sm'>{t`This stock item has expired`}</Text>
);
} else if (record.stale) {
extra.push(
<Text key='stale' size='sm'>{t`This stock item is stale`}</Text>
);
}
if (record.in_stock) {
if (allocated > 0) {
if (allocated > quantity) {
color = 'red';
extra.push(
<Text
key='over-allocated'
size='sm'
>{t`This stock item is over-allocated`}</Text>
);
} else if (allocated == quantity) {
color = 'orange';
extra.push(
<Text
key='fully-allocated'
size='sm'
>{t`This stock item is fully allocated`}</Text>
);
} else {
extra.push(
<Text
key='partially-allocated'
size='sm'
>{t`This stock item is partially allocated`}</Text>
);
}
}
if (available != quantity) {
if (available > 0) {
extra.push(
<Text key='available' size='sm' c='orange'>
{`${t`Available`}: ${formatDecimal(available)}`}
</Text>
);
} else {
extra.push(
<Text
key='no-stock'
size='sm'
c='red'
>{t`No stock available`}</Text>
);
}
}
if (quantity <= 0) {
extra.push(
<Text
key='depleted'
size='sm'
>{t`This stock item has been depleted`}</Text>
);
}
}
if (!record.in_stock) {
color = 'red';
}
return (
<TableHoverCard
value={
<Group gap='xs' justify='left' wrap='nowrap'>
<Text c={color}>{text}</Text>
{part.units && (
<Text size='xs' c={color}>
[{part.units}]
</Text>
)}
</Group>
}
title={t`Stock Information`}
extra={extra}
/>
);
}
};
}
export function CompanyColumn({
company
}: {

View File

@@ -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),

View File

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

View File

@@ -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

View File

@@ -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(
<Text
key='production'
size='sm'
>{t`This stock item is in production`}</Text>
);
} else if (record.sales_order) {
extra.push(
<Text
key='sales-order'
size='sm'
>{t`This stock item has been assigned to a sales order`}</Text>
);
} else if (record.customer) {
extra.push(
<Text
key='customer'
size='sm'
>{t`This stock item has been assigned to a customer`}</Text>
);
} else if (record.belongs_to) {
extra.push(
<Text
key='belongs-to'
size='sm'
>{t`This stock item is installed in another stock item`}</Text>
);
} else if (record.consumed_by) {
extra.push(
<Text
key='consumed-by'
size='sm'
>{t`This stock item has been consumed by a build order`}</Text>
);
} else if (!record.in_stock) {
extra.push(
<Text
key='unavailable'
size='sm'
>{t`This stock item is unavailable`}</Text>
);
}
if (record.expired) {
extra.push(
<Text
key='expired'
size='sm'
>{t`This stock item has expired`}</Text>
);
} else if (record.stale) {
extra.push(
<Text key='stale' size='sm'>{t`This stock item is stale`}</Text>
);
}
if (record.in_stock) {
if (allocated > 0) {
if (allocated > quantity) {
color = 'red';
extra.push(
<Text
key='over-allocated'
size='sm'
>{t`This stock item is over-allocated`}</Text>
);
} else if (allocated == quantity) {
color = 'orange';
extra.push(
<Text
key='fully-allocated'
size='sm'
>{t`This stock item is fully allocated`}</Text>
);
} else {
extra.push(
<Text
key='partially-allocated'
size='sm'
>{t`This stock item is partially allocated`}</Text>
);
}
}
if (available != quantity) {
if (available > 0) {
extra.push(
<Text key='available' size='sm' c='orange'>
{`${t`Available`}: ${formatDecimal(available)}`}
</Text>
);
} else {
extra.push(
<Text
key='no-stock'
size='sm'
c='red'
>{t`No stock available`}</Text>
);
}
}
if (quantity <= 0) {
extra.push(
<Text
key='depleted'
size='sm'
>{t`This stock item has been depleted`}</Text>
);
}
}
if (!record.in_stock) {
color = 'red';
}
return (
<TableHoverCard
value={
<Group gap='xs' justify='left' wrap='nowrap'>
<Text c={color}>{text}</Text>
{part.units && (
<Text size='xs' c={color}>
[{part.units}]
</Text>
)}
</Group>
}
title={t`Stock Information`}
extra={extra}
/>
);
}
},
sortable: true,
ordering: 'stock'
}),
StatusColumn({ model: ModelType.stockitem }),
{
accessor: 'batch',

View File

@@ -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: (
<Text size='sm' c='red'>{t`Stock item no longer exists`}</Text>
),
sortable: false,
switchable: false,
hidden: !partId,
render: (record: any) => {
const item = record.item_detail;
if (!item) {
return (
<Text
c='red'
size='xs'
fs='italic'
>{t`Stock item no longer exists`}</Text>
);
} else if (item.serial && item.quantity == 1) {
return `${t`Serial`} #${item.serial}`;
} else {
return `${t`Item ID`} ${item.pk}`;
}
}
},
hidden: !partId
}),
DescriptionColumn({
accessor: 'label'
}),

View File

@@ -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 }) => {