diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 699aac8629..7e6f95cb9a 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -26,12 +26,14 @@ from mptt.managers import TreeManager from mptt.models import MPTTModel, TreeForeignKey from taggit.managers import TaggableManager +import build.models import common.models import InvenTree.exceptions import InvenTree.helpers import InvenTree.models import InvenTree.ready import InvenTree.tasks +import order.models import report.mixins import stock.tasks from common.icons import validate_icon @@ -556,24 +558,51 @@ class StockItem( kwargs.pop('id', None) kwargs.pop('pk', None) - part = kwargs.get('part') - - if not part: - raise ValidationError({'part': _('Part must be specified')}) - # Create a list of StockItem objects items = [] # Provide some default field values data = {**kwargs} - # Remove some extraneous keys which cause issues - for key in ['parent_id', 'part_id', 'build_id']: - data.pop(key, None) + # Extract foreign-key fields from the provided data + fk_relations = { + 'parent': StockItem, + 'part': PartModels.Part, + 'build': build.models.Build, + 'purchase_order': order.models.PurchaseOrder, + 'supplier_part': CompanyModels.SupplierPart, + 'location': StockLocation, + 'belongs_to': StockItem, + 'customer': CompanyModels.Company, + 'consumed_by': build.models.Build, + 'sales_order': order.models.SalesOrder, + } + for field, model in fk_relations.items(): + if instance_id := data.pop(f'{field}_id', None): + try: + instance = model.objects.get(pk=instance_id) + data[field] = instance + except (ValueError, model.DoesNotExist): + raise ValidationError({field: _(f'{field} does not exist')}) + + # Remove some fields which we do not want copied across + for field in [ + 'barcode_data', + 'barcode_hash', + 'stocktake_date', + 'stocktake_user', + 'stocktake_user_id', + ]: + data.pop(field, None) + + if 'part' not in data: + raise ValidationError({'part': _('Part must be specified')}) + + part = data['part'] tree_id = kwargs.pop('tree_id', 0) - data['parent'] = kwargs.pop('parent', None) + data['parent'] = kwargs.pop('parent', None) or data.get('parent') data['tree_id'] = tree_id data['level'] = kwargs.pop('level', 0) data['rght'] = kwargs.pop('rght', 0) @@ -586,6 +615,7 @@ class StockItem( data['serial'] = serial data['serial_int'] = StockItem.convert_serial_to_int(serial) + # Construct a new StockItem from the provided dict items.append(StockItem(**data)) # Create the StockItem objects in bulk @@ -1786,7 +1816,7 @@ class StockItem( if location: data['location'] = location - data['part'] = self.part + # Set the parent ID correctly data['parent'] = self data['tree_id'] = self.tree_id @@ -1797,6 +1827,7 @@ class StockItem( history_items = [] for item in items: + # Construct a tracking entry for the new StockItem if entry := item.add_tracking_entry( StockHistoryCode.ASSIGNED_SERIAL, user, @@ -1807,20 +1838,11 @@ class StockItem( ): history_items.append(entry) + # Copy any test results from this item to the new one + item.copyTestResultsFrom(self) + StockItemTracking.objects.bulk_create(history_items) - # Duplicate test results - test_results = [] - - for test_result in self.test_results.all(): - for item in items: - test_result.pk = None - test_result.stock_item = item - - test_results.append(test_result) - - StockItemTestResult.objects.bulk_create(test_results) - # Remove the equivalent number of items self.take_stock(quantity, user, notes=notes) @@ -1835,17 +1857,24 @@ class StockItem( item.save() @transaction.atomic - def copyTestResultsFrom(self, other, filters=None): + def copyTestResultsFrom(self, other: StockItem, filters: Optional[dict] = None): """Copy all test results from another StockItem.""" # Set default - see B006 - if filters is None: - filters = {} - for result in other.test_results.all().filter(**filters): + results = other.test_results.all() + + if filters: + results = results.filter(**filters) + + results_to_create = [] + + for result in list(results): # Create a copy of the test result by nulling-out the pk result.pk = None result.stock_item = self - result.save() + results_to_create.append(result) + + StockItemTestResult.objects.bulk_create(results_to_create) def add_test_result(self, create_template=True, **kwargs): """Helper function to add a new StockItemTestResult. diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index 243d7b698d..5e7abe5fc3 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -340,9 +340,10 @@ export function useStockItemSerializeFields({ partId: number; trackable: boolean; modalId: string; -}) { +}): ApiFormFieldSet { const serialGenerator = useSerialNumberGenerator({ modalId: modalId, + isEnabled: () => trackable, initialQuery: { part: partId } diff --git a/src/frontend/src/hooks/UseGenerator.tsx b/src/frontend/src/hooks/UseGenerator.tsx index 3bfa6451f7..0f57b39ed0 100644 --- a/src/frontend/src/hooks/UseGenerator.tsx +++ b/src/frontend/src/hooks/UseGenerator.tsx @@ -79,6 +79,7 @@ export function useGenerator(props: GeneratorProps): GeneratorState { 'generator', props.key, props.endpoint, + props.modalId, props.initialQuery, modalState.openModals, debouncedQuery diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index 9dec661b09..cc09e1a5b5 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -863,7 +863,6 @@ export default function StockDetail() { name: t`Serialize`, tooltip: t`Serialize stock`, hidden: - isBuilding || serialized || stockitem?.quantity < 1 || stockitem?.part_detail?.trackable != true, @@ -1038,15 +1037,15 @@ export default function StockDetail() { id={stockitem.pk} instance={stockitem} /> - {editStockItem.modal} - {duplicateStockItem.modal} - {deleteStockItem.modal} - {serializeStockItem.modal} - {returnStockItem.modal} - {stockAdjustActions.modals.map((modal) => modal.modal)} - {orderPartsWizard.wizard} + {editStockItem.modal} + {duplicateStockItem.modal} + {deleteStockItem.modal} + {serializeStockItem.modal} + {returnStockItem.modal} + {stockAdjustActions.modals.map((modal) => modal.modal)} + {orderPartsWizard.wizard} ); } diff --git a/src/frontend/src/tables/build/BuildOutputTable.tsx b/src/frontend/src/tables/build/BuildOutputTable.tsx index bff67ae96b..c5aa65ae49 100644 --- a/src/frontend/src/tables/build/BuildOutputTable.tsx +++ b/src/frontend/src/tables/build/BuildOutputTable.tsx @@ -38,7 +38,8 @@ import { } from '../../forms/BuildForms'; import { type StockOperationProps, - useStockFields + useStockFields, + useStockItemSerializeFields } from '../../forms/StockForms'; import { InvenTreeIcon } from '../../functions/icons'; import { @@ -356,6 +357,28 @@ export default function BuildOutputTable({ } }); + const serializeStockFields = useStockItemSerializeFields({ + partId: selectedOutputs[0]?.part, + trackable: selectedOutputs[0]?.part_detail?.trackable, + modalId: 'build-output-serialize' + }); + + const serializeOutput = useCreateApiFormModal({ + url: ApiEndpoints.stock_serialize, + pk: selectedOutputs[0]?.pk, + title: t`Serialize Build Output`, + modalId: 'build-output-serialize', + fields: serializeStockFields, + initialData: { + quantity: selectedOutputs[0]?.quantity ?? 1, + destination: selectedOutputs[0]?.location ?? build.destination + }, + onFormSuccess: () => { + table.refreshTable(true); + refreshBuild(); + } + }); + const tableFilters: TableFilter[] = useMemo(() => { return [ { @@ -475,6 +498,17 @@ export default function BuildOutputTable({ deallocateBuildOutput.open(); } }, + { + title: t`Serialize`, + tooltip: t`Serialize build output`, + color: 'blue', + hidden: !record.part_detail?.trackable || !!record.serial, + icon: , + onClick: () => { + setSelectedOutputs([record]); + serializeOutput.open(); + } + }, { title: t`Complete`, tooltip: t`Complete build output`, @@ -627,6 +661,7 @@ export default function BuildOutputTable({ {editBuildOutput.modal} {deallocateBuildOutput.modal} {cancelBuildOutputsForm.modal} + {serializeOutput.modal} {stockAdjustActions.modals.map((modal) => modal.modal)} { - return [ - { - accessor: 'test', - title: t`Test`, - switchable: false, - sortable: true, - render: (record: any) => { - const enabled = record.enabled ?? record.template_detail?.enabled; - const installed = - record.stock_item != undefined && record.stock_item != itemId; + const constructTableColumns = useCallback( + (child: boolean) => { + return [ + { + accessor: 'test', + title: t`Test`, + switchable: false, + sortable: true, + render: (record: any) => { + const enabled = record.enabled ?? record.template_detail?.enabled; + const installed = + record.stock_item != undefined && record.stock_item != itemId; + + const multipleResults = record.results && record.results.length > 1; - return ( - - - {!record.templateId && '- '} - {record.test_name ?? record.template_detail?.test_name} - - - {record.results && record.results.length > 1 && ( - - - {record.results.length} - - - )} - {installed && ( - - - - )} - - - ); - } - }, - { - accessor: 'result', - title: t`Result`, - switchable: false, - sortable: true, - render: (record: any) => { - if (record.result === undefined) { return ( - {t`No Result`} + + {!child && ( + + )} + + {!record.templateId && '- '} + {record.test_name ?? record.template_detail?.test_name} + + + {record.results && record.results.length > 1 && ( + + + {record.results.length} + + + )} + {installed && ( + + + + )} + + + ); + } + }, + { + accessor: 'result', + title: t`Result`, + switchable: false, + sortable: true, + render: (record: any) => { + if (record.result === undefined) { + return ( + {t`No Result`} + ); + } else { + return ; + } + } + }, + DescriptionColumn({}), + { + accessor: 'value', + title: t`Value` + }, + { + accessor: 'attachment', + title: t`Attachment`, + render: (record: any) => + record.attachment && ( + + ), + noContext: true + }, + NoteColumn({}), + DateColumn({}), + { + accessor: 'user', + title: t`User`, + sortable: false, + render: (record: any) => + record.user_detail && + }, + { + accessor: 'test_station', + sortable: true, + title: t`Test station`, + hidden: !includeTestStation + }, + { + accessor: 'started_datetime', + sortable: true, + title: t`Started`, + hidden: !includeTestStation, + render: (record: any) => { + return ( + + {formatDate(record.started_datetime, { + showTime: true, + showSeconds: true + })} + + ); + } + }, + { + accessor: 'finished_datetime', + sortable: true, + title: t`Finished`, + hidden: !includeTestStation, + render: (record: any) => { + return ( + + {formatDate(record.finished_datetime, { + showTime: true, + showSeconds: true + })} + ); - } else { - return ; } } - }, - DescriptionColumn({}), - { - accessor: 'value', - title: t`Value` - }, - { - accessor: 'attachment', - title: t`Attachment`, - render: (record: any) => - record.attachment && ( - - ), - noContext: true - }, - NoteColumn({}), - DateColumn({}), - { - accessor: 'user', - title: t`User`, - sortable: false, - render: (record: any) => - record.user_detail && - }, - { - accessor: 'test_station', - sortable: true, - title: t`Test station`, - hidden: !includeTestStation - }, - { - accessor: 'started_datetime', - sortable: true, - title: t`Started`, - hidden: !includeTestStation, - render: (record: any) => { - return ( - - {formatDate(record.started_datetime, { - showTime: true, - showSeconds: true - })} - - ); - } - }, - { - accessor: 'finished_datetime', - sortable: true, - title: t`Finished`, - hidden: !includeTestStation, - render: (record: any) => { - return ( - - {formatDate(record.finished_datetime, { - showTime: true, - showSeconds: true - })} - - ); - } - } - ]; - }, [itemId, includeTestStation]); + ]; + }, + [itemId, includeTestStation, table.expandedRecords] + ); + + const tableColumns: TableColumn[] = useMemo(() => { + return constructTableColumns(false); + }, [itemId, includeTestStation, table.expandedRecords]); const [selectedTemplate, setSelectedTemplate] = useState( undefined @@ -286,7 +302,7 @@ export default function StockItemTestResultTable({ pk: selectedTest, fields: useMemo(() => ({ ...editResultFields }), [editResultFields]), title: t`Edit Test Result`, - table: table, + onFormSuccess: () => table.refreshTable, successMessage: t`Test result updated` }); @@ -418,9 +434,9 @@ export default function StockItemTestResultTable({ }, [user]); // Row expansion controller - const rowExpansion: any = useMemo(() => { + const rowExpansion: DataTableRowExpansionProps = useMemo(() => { const cols: any = [ - ...tableColumns, + ...constructTableColumns(true), { accessor: 'actions', title: ' ', @@ -435,7 +451,12 @@ export default function StockItemTestResultTable({ return { allowMultiple: true, - expandable: (record: any) => record.results && record.results.length > 1, + expandable: ({ record }: { record: any }) => { + return ( + table.isRowExpanded(record.pk) || + (record.results && record.results.length > 1) + ); + }, content: ({ record }: { record: any }) => { if (!record || !record.results || record.results.length < 2) { return null; @@ -454,7 +475,7 @@ export default function StockItemTestResultTable({ ); } }; - }, []); + }, [constructTableColumns, table.isRowExpanded]); return ( <> diff --git a/src/frontend/tests/pages/pui_stock.spec.ts b/src/frontend/tests/pages/pui_stock.spec.ts index f4683570e1..ba218307c6 100644 --- a/src/frontend/tests/pages/pui_stock.spec.ts +++ b/src/frontend/tests/pages/pui_stock.spec.ts @@ -1,4 +1,4 @@ -import { test } from '../baseFixtures.js'; +import { expect, test } from '../baseFixtures.js'; import { clearTableFilters, clickButtonIfVisible, @@ -199,12 +199,25 @@ test('Stock - Serialize', async ({ browser }) => { await page.getByLabel('action-menu-stock-operations').click(); await page.getByLabel('action-menu-stock-operations-serialize').click(); + // Check for expected placeholder value + await expect( + page.getByRole('textbox', { name: 'text-field-serial_numbers' }) + ).toHaveAttribute('placeholder', 'Next serial number: 365'); + await page.getByLabel('text-field-serial_numbers').fill('200-250'); await page.getByRole('button', { name: 'Submit' }).click(); await page .getByText('Group range 200-250 exceeds allowed quantity') .waitFor(); + + await page.getByLabel('text-field-serial_numbers').fill('1, 2, 3'); + await page.waitForTimeout(250); + await page.getByRole('button', { name: 'Submit' }).click(); + await page + .getByText('Number of unique serial numbers (3) must match quantity (10)') + .waitFor(); + await page.getByRole('button', { name: 'Cancel' }).click(); });