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();
});