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