diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py
index b73b36ca8e..c006312851 100644
--- a/src/backend/InvenTree/part/models.py
+++ b/src/backend/InvenTree/part/models.py
@@ -2636,9 +2636,7 @@ class Part(
parts = []
# Child parts
- children = self.get_descendants(include_self=False)
-
- for child in children:
+ for child in self.get_descendants(include_self=False):
parts.append(child)
# Immediate parent, and siblings
diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx
index 8a1a80ad73..addb35378b 100644
--- a/src/frontend/lib/enums/ApiEndpoints.tsx
+++ b/src/frontend/lib/enums/ApiEndpoints.tsx
@@ -153,6 +153,7 @@ export enum ApiEndpoints {
stock_merge = 'stock/merge/',
stock_assign = 'stock/assign/',
stock_status = 'stock/status/',
+ stock_convert = 'stock/:id/convert/',
stock_install = 'stock/:id/install/',
stock_uninstall = 'stock/:id/uninstall/',
stock_serialize = 'stock/:id/serialize/',
diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx
index c508083616..79b076b346 100644
--- a/src/frontend/src/pages/stock/StockDetail.tsx
+++ b/src/frontend/src/pages/stock/StockDetail.tsx
@@ -21,7 +21,8 @@ import {
IconPackages,
IconSearch,
IconShoppingCart,
- IconSitemap
+ IconSitemap,
+ IconTransform
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { type ReactNode, useMemo, useState } from 'react';
@@ -33,7 +34,7 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl, getOverviewUrl } from '@lib/functions/Navigation';
-import type { StockOperationProps } from '@lib/types/Forms';
+import type { ApiFormFieldSet, StockOperationProps } from '@lib/types/Forms';
import { notifications } from '@mantine/notifications';
import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog';
import AdminButton from '../../components/buttons/AdminButton';
@@ -665,6 +666,26 @@ export default function StockDetail() {
onFormSuccess: refreshInstance
});
+ const convertStockItemFields: ApiFormFieldSet = useMemo(() => {
+ return {
+ part: {
+ filters: {
+ active: true,
+ convert_from: stockitem.part
+ }
+ }
+ };
+ }, [stockitem]);
+
+ const convertStockItem = useCreateApiFormModal({
+ url: ApiEndpoints.stock_convert,
+ pk: stockitem.pk,
+ title: t`Convert Stock Item`,
+ modalId: 'convert-stock-item',
+ fields: convertStockItemFields,
+ onFormSuccess: refreshInstance
+ });
+
const duplicateStockItemFields = useStockFields({
create: true,
modalId: 'duplicate-stock-item'
@@ -824,16 +845,6 @@ export default function StockDetail() {
});
const stockActions = useMemo(() => {
- // Can this stock item be transferred to a different location?
- const canTransfer =
- user.hasChangeRole(UserRoles.stock) &&
- !stockitem.sales_order &&
- !stockitem.belongs_to &&
- !stockitem.customer &&
- !stockitem.consumed_by;
-
- const isBuilding = stockitem.is_building;
-
const serial = stockitem.serial;
const serialized =
serial != null &&
@@ -841,6 +852,10 @@ export default function StockDetail() {
serial != '' &&
stockitem.quantity == 1;
+ const canConvert =
+ !!stockitem.part_detail?.variant_of ||
+ !!stockitem.part_detail?.is_template;
+
return [
,
,
@@ -906,6 +921,13 @@ export default function StockDetail() {
hidden: !user.hasChangeRole(UserRoles.stock),
onClick: () => editStockItem.open()
}),
+ {
+ name: t`Convert`,
+ tooltip: t`Convert this stock item to a different part`,
+ hidden: !user.hasChangeRole(UserRoles.stock) || !canConvert,
+ icon: ,
+ onClick: () => convertStockItem.open()
+ },
DeleteItemAction({
hidden: !user.hasDeleteRole(UserRoles.stock),
onClick: () => deleteStockItem.open()
@@ -1037,8 +1059,9 @@ export default function StockDetail() {
{editStockItem.modal}
- {duplicateStockItem.modal}
{deleteStockItem.modal}
+ {convertStockItem.modal}
+ {duplicateStockItem.modal}
{serializeStockItem.modal}
{stockAdjustActions.modals.map((modal) => modal.modal)}
{orderPartsWizard.wizard}
diff --git a/src/frontend/tests/pages/pui_stock.spec.ts b/src/frontend/tests/pages/pui_stock.spec.ts
index ac861ebfe8..0592576892 100644
--- a/src/frontend/tests/pages/pui_stock.spec.ts
+++ b/src/frontend/tests/pages/pui_stock.spec.ts
@@ -393,6 +393,39 @@ test('Stock - Stock Actions', async ({ browser }) => {
await page.getByLabel('action-menu-stock-operations-return').click();
});
+// Test conversion between part variants
+test('Stock - Convert', async ({ browser }) => {
+ const page = await doCachedLogin(browser, { url: 'stock/item/242/details' });
+
+ await page.getByText('widget.red.00 | Red Widget |').waitFor();
+
+ // Convert to widget.red.02
+ await page
+ .getByRole('button', { name: 'action-menu-stock-item-actions' })
+ .click();
+ await page
+ .getByRole('menuitem', { name: 'action-menu-stock-item-actions-convert' })
+ .click();
+ await page.getByRole('combobox', { name: 'related-field-part' }).fill('red');
+ await page.getByText('widget.red.02 | Red Widget |').click();
+ await page.getByRole('button', { name: 'Submit' }).click();
+
+ await page.getByText('widget.red.02 | Red Widget |').waitFor();
+
+ // Convert to widget.red.00
+ await page
+ .getByRole('button', { name: 'action-menu-stock-item-actions' })
+ .click();
+ await page
+ .getByRole('menuitem', { name: 'action-menu-stock-item-actions-convert' })
+ .click();
+ await page.getByRole('combobox', { name: 'related-field-part' }).fill('red');
+ await page.getByText('widget.red.00 | Red Widget |').click();
+ await page.getByRole('button', { name: 'Submit' }).click();
+
+ await page.getByText('widget.red.00 | Red Widget |').waitFor();
+});
+
test('Stock - Return Items', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: 'sales/customer/32/assigned-stock'