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 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.

View File

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

View File

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

View File

@ -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,6 +1037,8 @@ export default function StockDetail() {
id={stockitem.pk}
instance={stockitem}
/>
</Stack>
</InstanceDetail>
{editStockItem.modal}
{duplicateStockItem.modal}
{deleteStockItem.modal}
@ -1045,8 +1046,6 @@ export default function StockDetail() {
{returnStockItem.modal}
{stockAdjustActions.modals.map((modal) => modal.modal)}
{orderPartsWizard.wizard}
</Stack>
</InstanceDetail>
</>
);
}

View File

@ -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: <InvenTreeIcon icon='serial' />,
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)}
<OutputAllocationDrawer
build={build}

View File

@ -7,7 +7,7 @@ import {
IconInfoCircle
} from '@tabler/icons-react';
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 { ApiEndpoints } from '@lib/enums/ApiEndpoints';
@ -39,6 +39,7 @@ import {
RowDeleteAction,
RowEditAction
} from '../RowActions';
import RowExpansionIcon from '../RowExpansionIcon';
export default function StockItemTestResultTable({
partId,
@ -131,7 +132,8 @@ export default function StockItemTestResultTable({
[partId, itemId, testTemplates]
);
const tableColumns: TableColumn[] = useMemo(() => {
const constructTableColumns = useCallback(
(child: boolean) => {
return [
{
accessor: 'test',
@ -143,8 +145,16 @@ export default function StockItemTestResultTable({
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'>
{!child && (
<RowExpansionIcon
enabled={multipleResults}
expanded={table.isRowExpanded(record.pk)}
/>
)}
<Text
style={{ fontStyle: installed ? 'italic' : undefined }}
c={enabled ? undefined : 'red'}
@ -247,7 +257,13 @@ export default function StockItemTestResultTable({
}
}
];
}, [itemId, includeTestStation]);
},
[itemId, includeTestStation, table.expandedRecords]
);
const tableColumns: TableColumn[] = useMemo(() => {
return constructTableColumns(false);
}, [itemId, includeTestStation, table.expandedRecords]);
const [selectedTemplate, setSelectedTemplate] = useState<number | undefined>(
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<any> = 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 (
<>

View File

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