From 427eb6d4b3e4217b1f21059ad28e06c8fee96d9a Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sat, 4 Jul 2026 14:18:46 +0000 Subject: [PATCH] Implement duplicate action for stock item --- src/backend/InvenTree/stock/api.py | 23 ++++++++++++++++++ src/backend/InvenTree/stock/serializers.py | 25 ++++++++++++++++++++ src/frontend/src/forms/StockForms.tsx | 25 +++++++++++++++++++- src/frontend/src/pages/stock/StockDetail.tsx | 3 ++- 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index 6557761651..dcefb7287b 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -1250,9 +1250,28 @@ class StockList( serializer = self.get_serializer(data=data) serializer.is_valid(raise_exception=True) + # Extract 'duplicate' options (if provided) - these are not valid model fields + duplicate = serializer.validated_data.pop('duplicate', None) + # Extract location information location = serializer.validated_data.get('location', None) + def apply_duplicate_options(item): + """Apply any provided 'duplicate' options to a newly created StockItem.""" + if not duplicate: + return + + original = duplicate['original'] + + if duplicate.get('copy_notes', True): + item.copy_notes_from(original) + + if duplicate.get('copy_history', False): + item.copyHistoryFrom(original) + + if duplicate.get('copy_tests', False): + item.copyTestResultsFrom(original) + with transaction.atomic(): if serials: # Create multiple serialized StockItem objects @@ -1268,6 +1287,8 @@ class StockList( item.set_status(status_value) item.save() + apply_duplicate_options(item) + if entry := item.add_tracking_entry( StockHistoryCode.CREATED, user, @@ -1300,6 +1321,8 @@ class StockList( item.save(user=user) item.refresh_from_db() + apply_duplicate_options(item) + response_data = [ StockSerializers.StockItemSerializer( item, context=self.get_serializer_context() diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index aeffc94001..836c7e414b 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -324,6 +324,8 @@ class StockItemSerializer( export_exclude_fields = ['tags', 'tracking_items'] + SKIP_CREATE_FIELDS = ['duplicate'] + export_child_fields = [ 'part_detail.name', 'part_detail.description', @@ -380,6 +382,7 @@ class StockItemSerializer( 'purchase_price_currency', 'use_pack_size', 'serial_numbers', + 'duplicate', # Annotated fields 'allocated', 'expired', @@ -467,6 +470,28 @@ class StockItemSerializer( help_text=_('Enter serial numbers for new items'), ) + # Extra field used only for creation of a new StockItem instance + duplicate = InvenTree.serializers.DuplicateOptionsSerializer( + StockItem.objects.all(), + label=_('Duplicate Stock Item'), + help_text=_('Copy initial data from another stock item'), + copy_notes=True, + copy_fields=[ + { + 'name': 'copy_tests', + 'label': _('Copy Test Results'), + 'help_text': _('Copy test results from the original stock item'), + 'default': False, + }, + { + 'name': 'copy_history', + 'label': _('Copy History'), + 'help_text': _('Copy stock history from the original stock item'), + 'default': False, + }, + ], + ) + def validate_part(self, part): """Ensure the provided Part instance is valid.""" if part.virtual: diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index c8c253fd66..bfd0f228b6 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -27,6 +27,7 @@ import { modals } from '@mantine/modals'; import { IconCalendarExclamation, IconCoins, + IconCopy, IconCurrencyDollar, IconLink, IconPackage, @@ -81,7 +82,8 @@ export function useStockFields({ create = false, supplierPartId, pricing, - modalId + modalId, + duplicateStockItem }: { partId?: number; stockItem?: any; @@ -89,6 +91,7 @@ export function useStockFields({ create: boolean; supplierPartId?: number; pricing?: { [priceBreak: number]: [number, string] }; + duplicateStockItem?: any; }): ApiFormFieldSet { const globalSettings = useGlobalSettingsState(); @@ -301,6 +304,25 @@ export function useStockFields({ delete fields.serial_numbers; } + // Additional fields for stock item duplication + if (create && duplicateStockItem?.pk) { + fields.duplicate = { + icon: , + children: { + original: { + value: duplicateStockItem.pk, + hidden: true + }, + copy_notes: { value: true }, + copy_history: { value: false }, + copy_tests: { + value: false, + hidden: !duplicateStockItem?.part_detail?.testable + } + } + }; + } + return fields; }, [ stockItem, @@ -315,6 +337,7 @@ export function useStockFields({ purchasePriceCurrency, serialGenerator.result, batchGenerator.result, + duplicateStockItem, create ]); } diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index 5e118b4c6a..94e306ce8d 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -742,7 +742,8 @@ export default function StockDetail() { const duplicateStockItemFields = useStockFields({ create: true, - modalId: 'duplicate-stock-item' + modalId: 'duplicate-stock-item', + duplicateStockItem: stockitem }); const duplicateStockData = useMemo(() => {