2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-18 10:46:31 +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

@@ -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,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}
</Stack>
</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';
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,123 +132,138 @@ export default function StockItemTestResultTable({
[partId, itemId, testTemplates]
);
const tableColumns: TableColumn[] = useMemo(() => {
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 (
<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 (
<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({}),
{
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>
);
}
}
];
}, [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();
});