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'