2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-04-28 13:54:25 +00:00

Stock convert (#11818)

* [UI] Reimplement "convert stock" action

* Add playwright tests
This commit is contained in:
Oliver
2026-04-27 19:59:30 +10:00
committed by GitHub
parent 9958f4a247
commit 65b2283c7f
4 changed files with 71 additions and 16 deletions
+1 -3
View File
@@ -2636,9 +2636,7 @@ class Part(
parts = [] parts = []
# Child parts # Child parts
children = self.get_descendants(include_self=False) for child in self.get_descendants(include_self=False):
for child in children:
parts.append(child) parts.append(child)
# Immediate parent, and siblings # Immediate parent, and siblings
+1
View File
@@ -153,6 +153,7 @@ export enum ApiEndpoints {
stock_merge = 'stock/merge/', stock_merge = 'stock/merge/',
stock_assign = 'stock/assign/', stock_assign = 'stock/assign/',
stock_status = 'stock/status/', stock_status = 'stock/status/',
stock_convert = 'stock/:id/convert/',
stock_install = 'stock/:id/install/', stock_install = 'stock/:id/install/',
stock_uninstall = 'stock/:id/uninstall/', stock_uninstall = 'stock/:id/uninstall/',
stock_serialize = 'stock/:id/serialize/', stock_serialize = 'stock/:id/serialize/',
+36 -13
View File
@@ -21,7 +21,8 @@ import {
IconPackages, IconPackages,
IconSearch, IconSearch,
IconShoppingCart, IconShoppingCart,
IconSitemap IconSitemap,
IconTransform
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { type ReactNode, useMemo, useState } from 'react'; import { type ReactNode, useMemo, useState } from 'react';
@@ -33,7 +34,7 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl, getOverviewUrl } from '@lib/functions/Navigation'; 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 { notifications } from '@mantine/notifications';
import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog'; import { useBarcodeScanDialog } from '../../components/barcodes/BarcodeScanDialog';
import AdminButton from '../../components/buttons/AdminButton'; import AdminButton from '../../components/buttons/AdminButton';
@@ -665,6 +666,26 @@ export default function StockDetail() {
onFormSuccess: refreshInstance 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({ const duplicateStockItemFields = useStockFields({
create: true, create: true,
modalId: 'duplicate-stock-item' modalId: 'duplicate-stock-item'
@@ -824,16 +845,6 @@ export default function StockDetail() {
}); });
const stockActions = useMemo(() => { 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 serial = stockitem.serial;
const serialized = const serialized =
serial != null && serial != null &&
@@ -841,6 +852,10 @@ export default function StockDetail() {
serial != '' && serial != '' &&
stockitem.quantity == 1; stockitem.quantity == 1;
const canConvert =
!!stockitem.part_detail?.variant_of ||
!!stockitem.part_detail?.is_template;
return [ return [
<AdminButton model={ModelType.stockitem} id={stockitem.pk} />, <AdminButton model={ModelType.stockitem} id={stockitem.pk} />,
<LocateItemButton stockId={stockitem.pk} />, <LocateItemButton stockId={stockitem.pk} />,
@@ -906,6 +921,13 @@ export default function StockDetail() {
hidden: !user.hasChangeRole(UserRoles.stock), hidden: !user.hasChangeRole(UserRoles.stock),
onClick: () => editStockItem.open() onClick: () => editStockItem.open()
}), }),
{
name: t`Convert`,
tooltip: t`Convert this stock item to a different part`,
hidden: !user.hasChangeRole(UserRoles.stock) || !canConvert,
icon: <IconTransform color='blue' />,
onClick: () => convertStockItem.open()
},
DeleteItemAction({ DeleteItemAction({
hidden: !user.hasDeleteRole(UserRoles.stock), hidden: !user.hasDeleteRole(UserRoles.stock),
onClick: () => deleteStockItem.open() onClick: () => deleteStockItem.open()
@@ -1037,8 +1059,9 @@ export default function StockDetail() {
</Stack> </Stack>
</InstanceDetail> </InstanceDetail>
{editStockItem.modal} {editStockItem.modal}
{duplicateStockItem.modal}
{deleteStockItem.modal} {deleteStockItem.modal}
{convertStockItem.modal}
{duplicateStockItem.modal}
{serializeStockItem.modal} {serializeStockItem.modal}
{stockAdjustActions.modals.map((modal) => modal.modal)} {stockAdjustActions.modals.map((modal) => modal.modal)}
{orderPartsWizard.wizard} {orderPartsWizard.wizard}
@@ -393,6 +393,39 @@ test('Stock - Stock Actions', async ({ browser }) => {
await page.getByLabel('action-menu-stock-operations-return').click(); 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 }) => { test('Stock - Return Items', async ({ browser }) => {
const page = await doCachedLogin(browser, { const page = await doCachedLogin(browser, {
url: 'sales/customer/32/assigned-stock' url: 'sales/customer/32/assigned-stock'