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:
@ -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.
|
||||
|
@ -340,9 +340,10 @@ export function useStockItemSerializeFields({
|
||||
partId: number;
|
||||
trackable: boolean;
|
||||
modalId: string;
|
||||
}) {
|
||||
}): ApiFormFieldSet {
|
||||
const serialGenerator = useSerialNumberGenerator({
|
||||
modalId: modalId,
|
||||
isEnabled: () => trackable,
|
||||
initialQuery: {
|
||||
part: partId
|
||||
}
|
||||
|
@ -79,6 +79,7 @@ export function useGenerator(props: GeneratorProps): GeneratorState {
|
||||
'generator',
|
||||
props.key,
|
||||
props.endpoint,
|
||||
props.modalId,
|
||||
props.initialQuery,
|
||||
modalState.openModals,
|
||||
debouncedQuery
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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 (
|
||||
<>
|
||||
|
@ -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();
|
||||
});
|
||||
|
||||
|
Reference in New Issue
Block a user