2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-09 15:10:54 +00:00

StockItem Serialization Improvements (#9968)

* Add "serialize" form to build output table

* Fix dependencies for useGenerator hook

* Improve serializing of stock

- Copy test results
- Ensure fields get copied across

* Fix rendering for StockItemTestResultTable

* Enhanced playwright test

* Fix code

* Fix for unit test
This commit is contained in:
Oliver
2025-07-07 13:48:58 +10:00
committed by GitHub
parent 1bbbde0b22
commit 33e1c58ed9
7 changed files with 255 additions and 156 deletions

View File

@ -26,12 +26,14 @@ from mptt.managers import TreeManager
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
import build.models
import common.models import common.models
import InvenTree.exceptions import InvenTree.exceptions
import InvenTree.helpers import InvenTree.helpers
import InvenTree.models import InvenTree.models
import InvenTree.ready import InvenTree.ready
import InvenTree.tasks import InvenTree.tasks
import order.models
import report.mixins import report.mixins
import stock.tasks import stock.tasks
from common.icons import validate_icon from common.icons import validate_icon
@ -556,24 +558,51 @@ class StockItem(
kwargs.pop('id', None) kwargs.pop('id', None)
kwargs.pop('pk', 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 # Create a list of StockItem objects
items = [] items = []
# Provide some default field values # Provide some default field values
data = {**kwargs} data = {**kwargs}
# Remove some extraneous keys which cause issues # Extract foreign-key fields from the provided data
for key in ['parent_id', 'part_id', 'build_id']: fk_relations = {
data.pop(key, None) '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) 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['tree_id'] = tree_id
data['level'] = kwargs.pop('level', 0) data['level'] = kwargs.pop('level', 0)
data['rght'] = kwargs.pop('rght', 0) data['rght'] = kwargs.pop('rght', 0)
@ -586,6 +615,7 @@ class StockItem(
data['serial'] = serial data['serial'] = serial
data['serial_int'] = StockItem.convert_serial_to_int(serial) data['serial_int'] = StockItem.convert_serial_to_int(serial)
# Construct a new StockItem from the provided dict
items.append(StockItem(**data)) items.append(StockItem(**data))
# Create the StockItem objects in bulk # Create the StockItem objects in bulk
@ -1786,7 +1816,7 @@ class StockItem(
if location: if location:
data['location'] = location data['location'] = location
data['part'] = self.part # Set the parent ID correctly
data['parent'] = self data['parent'] = self
data['tree_id'] = self.tree_id data['tree_id'] = self.tree_id
@ -1797,6 +1827,7 @@ class StockItem(
history_items = [] history_items = []
for item in items: for item in items:
# Construct a tracking entry for the new StockItem
if entry := item.add_tracking_entry( if entry := item.add_tracking_entry(
StockHistoryCode.ASSIGNED_SERIAL, StockHistoryCode.ASSIGNED_SERIAL,
user, user,
@ -1807,20 +1838,11 @@ class StockItem(
): ):
history_items.append(entry) history_items.append(entry)
# Copy any test results from this item to the new one
item.copyTestResultsFrom(self)
StockItemTracking.objects.bulk_create(history_items) 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 # Remove the equivalent number of items
self.take_stock(quantity, user, notes=notes) self.take_stock(quantity, user, notes=notes)
@ -1835,17 +1857,24 @@ class StockItem(
item.save() item.save()
@transaction.atomic @transaction.atomic
def copyTestResultsFrom(self, other, filters=None): def copyTestResultsFrom(self, other: StockItem, filters: Optional[dict] = None):
"""Copy all test results from another StockItem.""" """Copy all test results from another StockItem."""
# Set default - see B006 # 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 # Create a copy of the test result by nulling-out the pk
result.pk = None result.pk = None
result.stock_item = self 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): def add_test_result(self, create_template=True, **kwargs):
"""Helper function to add a new StockItemTestResult. """Helper function to add a new StockItemTestResult.

View File

@ -340,9 +340,10 @@ export function useStockItemSerializeFields({
partId: number; partId: number;
trackable: boolean; trackable: boolean;
modalId: string; modalId: string;
}) { }): ApiFormFieldSet {
const serialGenerator = useSerialNumberGenerator({ const serialGenerator = useSerialNumberGenerator({
modalId: modalId, modalId: modalId,
isEnabled: () => trackable,
initialQuery: { initialQuery: {
part: partId part: partId
} }

View File

@ -79,6 +79,7 @@ export function useGenerator(props: GeneratorProps): GeneratorState {
'generator', 'generator',
props.key, props.key,
props.endpoint, props.endpoint,
props.modalId,
props.initialQuery, props.initialQuery,
modalState.openModals, modalState.openModals,
debouncedQuery debouncedQuery

View File

@ -863,7 +863,6 @@ export default function StockDetail() {
name: t`Serialize`, name: t`Serialize`,
tooltip: t`Serialize stock`, tooltip: t`Serialize stock`,
hidden: hidden:
isBuilding ||
serialized || serialized ||
stockitem?.quantity < 1 || stockitem?.quantity < 1 ||
stockitem?.part_detail?.trackable != true, stockitem?.part_detail?.trackable != true,
@ -1038,15 +1037,15 @@ export default function StockDetail() {
id={stockitem.pk} id={stockitem.pk}
instance={stockitem} instance={stockitem}
/> />
{editStockItem.modal}
{duplicateStockItem.modal}
{deleteStockItem.modal}
{serializeStockItem.modal}
{returnStockItem.modal}
{stockAdjustActions.modals.map((modal) => modal.modal)}
{orderPartsWizard.wizard}
</Stack> </Stack>
</InstanceDetail> </InstanceDetail>
{editStockItem.modal}
{duplicateStockItem.modal}
{deleteStockItem.modal}
{serializeStockItem.modal}
{returnStockItem.modal}
{stockAdjustActions.modals.map((modal) => modal.modal)}
{orderPartsWizard.wizard}
</> </>
); );
} }

View File

@ -38,7 +38,8 @@ import {
} from '../../forms/BuildForms'; } from '../../forms/BuildForms';
import { import {
type StockOperationProps, type StockOperationProps,
useStockFields useStockFields,
useStockItemSerializeFields
} from '../../forms/StockForms'; } from '../../forms/StockForms';
import { InvenTreeIcon } from '../../functions/icons'; import { InvenTreeIcon } from '../../functions/icons';
import { 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(() => { const tableFilters: TableFilter[] = useMemo(() => {
return [ return [
{ {
@ -475,6 +498,17 @@ export default function BuildOutputTable({
deallocateBuildOutput.open(); deallocateBuildOutput.open();
} }
}, },
{
title: t`Serialize`,
tooltip: t`Serialize build output`,
color: 'blue',
hidden: !record.part_detail?.trackable || !!record.serial,
icon: <InvenTreeIcon icon='serial' />,
onClick: () => {
setSelectedOutputs([record]);
serializeOutput.open();
}
},
{ {
title: t`Complete`, title: t`Complete`,
tooltip: t`Complete build output`, tooltip: t`Complete build output`,
@ -627,6 +661,7 @@ export default function BuildOutputTable({
{editBuildOutput.modal} {editBuildOutput.modal}
{deallocateBuildOutput.modal} {deallocateBuildOutput.modal}
{cancelBuildOutputsForm.modal} {cancelBuildOutputsForm.modal}
{serializeOutput.modal}
{stockAdjustActions.modals.map((modal) => modal.modal)} {stockAdjustActions.modals.map((modal) => modal.modal)}
<OutputAllocationDrawer <OutputAllocationDrawer
build={build} build={build}

View File

@ -7,7 +7,7 @@ import {
IconInfoCircle IconInfoCircle
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { DataTable } from 'mantine-datatable'; import { DataTable, type DataTableRowExpansionProps } from 'mantine-datatable';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
@ -39,6 +39,7 @@ import {
RowDeleteAction, RowDeleteAction,
RowEditAction RowEditAction
} from '../RowActions'; } from '../RowActions';
import RowExpansionIcon from '../RowExpansionIcon';
export default function StockItemTestResultTable({ export default function StockItemTestResultTable({
partId, partId,
@ -131,123 +132,138 @@ export default function StockItemTestResultTable({
[partId, itemId, testTemplates] [partId, itemId, testTemplates]
); );
const tableColumns: TableColumn[] = useMemo(() => { const constructTableColumns = useCallback(
return [ (child: boolean) => {
{ return [
accessor: 'test', {
title: t`Test`, accessor: 'test',
switchable: false, title: t`Test`,
sortable: true, switchable: false,
render: (record: any) => { sortable: true,
const enabled = record.enabled ?? record.template_detail?.enabled; render: (record: any) => {
const installed = const enabled = record.enabled ?? record.template_detail?.enabled;
record.stock_item != undefined && record.stock_item != itemId; const installed =
record.stock_item != undefined && record.stock_item != itemId;
const multipleResults = record.results && record.results.length > 1;
return (
<Group justify='space-between' wrap='nowrap'>
<Text
style={{ fontStyle: installed ? 'italic' : undefined }}
c={enabled ? undefined : 'red'}
>
{!record.templateId && '- '}
{record.test_name ?? record.template_detail?.test_name}
</Text>
<Group justify='right'>
{record.results && record.results.length > 1 && (
<Tooltip label={t`Test Results`}>
<Badge color='lightblue' variant='filled'>
{record.results.length}
</Badge>
</Tooltip>
)}
{installed && (
<Tooltip label={t`Test result for installed stock item`}>
<IconInfoCircle size={16} color='blue' />
</Tooltip>
)}
</Group>
</Group>
);
}
},
{
accessor: 'result',
title: t`Result`,
switchable: false,
sortable: true,
render: (record: any) => {
if (record.result === undefined) {
return ( return (
<Badge color='lightblue' variant='filled'>{t`No Result`}</Badge> <Group justify='space-between' wrap='nowrap'>
{!child && (
<RowExpansionIcon
enabled={multipleResults}
expanded={table.isRowExpanded(record.pk)}
/>
)}
<Text
style={{ fontStyle: installed ? 'italic' : undefined }}
c={enabled ? undefined : 'red'}
>
{!record.templateId && '- '}
{record.test_name ?? record.template_detail?.test_name}
</Text>
<Group justify='right'>
{record.results && record.results.length > 1 && (
<Tooltip label={t`Test Results`}>
<Badge color='lightblue' variant='filled'>
{record.results.length}
</Badge>
</Tooltip>
)}
{installed && (
<Tooltip label={t`Test result for installed stock item`}>
<IconInfoCircle size={16} color='blue' />
</Tooltip>
)}
</Group>
</Group>
);
}
},
{
accessor: 'result',
title: t`Result`,
switchable: false,
sortable: true,
render: (record: any) => {
if (record.result === undefined) {
return (
<Badge color='lightblue' variant='filled'>{t`No Result`}</Badge>
);
} else {
return <PassFailButton value={record.result} />;
}
}
},
DescriptionColumn({}),
{
accessor: 'value',
title: t`Value`
},
{
accessor: 'attachment',
title: t`Attachment`,
render: (record: any) =>
record.attachment && (
<AttachmentLink attachment={record.attachment} />
),
noContext: true
},
NoteColumn({}),
DateColumn({}),
{
accessor: 'user',
title: t`User`,
sortable: false,
render: (record: any) =>
record.user_detail && <RenderUser instance={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 (
<Group justify='space-between'>
{formatDate(record.started_datetime, {
showTime: true,
showSeconds: true
})}
</Group>
);
}
},
{
accessor: 'finished_datetime',
sortable: true,
title: t`Finished`,
hidden: !includeTestStation,
render: (record: any) => {
return (
<Group justify='space-between'>
{formatDate(record.finished_datetime, {
showTime: true,
showSeconds: true
})}
</Group>
); );
} else {
return <PassFailButton value={record.result} />;
} }
} }
}, ];
DescriptionColumn({}), },
{ [itemId, includeTestStation, table.expandedRecords]
accessor: 'value', );
title: t`Value`
}, const tableColumns: TableColumn[] = useMemo(() => {
{ return constructTableColumns(false);
accessor: 'attachment', }, [itemId, includeTestStation, table.expandedRecords]);
title: t`Attachment`,
render: (record: any) =>
record.attachment && (
<AttachmentLink attachment={record.attachment} />
),
noContext: true
},
NoteColumn({}),
DateColumn({}),
{
accessor: 'user',
title: t`User`,
sortable: false,
render: (record: any) =>
record.user_detail && <RenderUser instance={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 (
<Group justify='space-between'>
{formatDate(record.started_datetime, {
showTime: true,
showSeconds: true
})}
</Group>
);
}
},
{
accessor: 'finished_datetime',
sortable: true,
title: t`Finished`,
hidden: !includeTestStation,
render: (record: any) => {
return (
<Group justify='space-between'>
{formatDate(record.finished_datetime, {
showTime: true,
showSeconds: true
})}
</Group>
);
}
}
];
}, [itemId, includeTestStation]);
const [selectedTemplate, setSelectedTemplate] = useState<number | undefined>( const [selectedTemplate, setSelectedTemplate] = useState<number | undefined>(
undefined undefined
@ -286,7 +302,7 @@ export default function StockItemTestResultTable({
pk: selectedTest, pk: selectedTest,
fields: useMemo(() => ({ ...editResultFields }), [editResultFields]), fields: useMemo(() => ({ ...editResultFields }), [editResultFields]),
title: t`Edit Test Result`, title: t`Edit Test Result`,
table: table, onFormSuccess: () => table.refreshTable,
successMessage: t`Test result updated` successMessage: t`Test result updated`
}); });
@ -418,9 +434,9 @@ export default function StockItemTestResultTable({
}, [user]); }, [user]);
// Row expansion controller // Row expansion controller
const rowExpansion: any = useMemo(() => { const rowExpansion: DataTableRowExpansionProps<any> = useMemo(() => {
const cols: any = [ const cols: any = [
...tableColumns, ...constructTableColumns(true),
{ {
accessor: 'actions', accessor: 'actions',
title: ' ', title: ' ',
@ -435,7 +451,12 @@ export default function StockItemTestResultTable({
return { return {
allowMultiple: true, 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 }) => { content: ({ record }: { record: any }) => {
if (!record || !record.results || record.results.length < 2) { if (!record || !record.results || record.results.length < 2) {
return null; return null;
@ -454,7 +475,7 @@ export default function StockItemTestResultTable({
); );
} }
}; };
}, []); }, [constructTableColumns, table.isRowExpanded]);
return ( return (
<> <>

View File

@ -1,4 +1,4 @@
import { test } from '../baseFixtures.js'; import { expect, test } from '../baseFixtures.js';
import { import {
clearTableFilters, clearTableFilters,
clickButtonIfVisible, 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').click();
await page.getByLabel('action-menu-stock-operations-serialize').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.getByLabel('text-field-serial_numbers').fill('200-250');
await page.getByRole('button', { name: 'Submit' }).click(); await page.getByRole('button', { name: 'Submit' }).click();
await page await page
.getByText('Group range 200-250 exceeds allowed quantity') .getByText('Group range 200-250 exceeds allowed quantity')
.waitFor(); .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(); await page.getByRole('button', { name: 'Cancel' }).click();
}); });