2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-15 19:45:46 +00:00

[UI] Fix serial and batch code generators (#9772)

* Tweak stock item form

- Fix batch code placeholder
- Fix serial number placeholder

* Tweak build output form

* More cleanup

* Fix for PurchaseOrderForm

* Refactoring placeholder values
This commit is contained in:
Oliver
2025-06-13 20:02:30 +10:00
committed by GitHub
parent 5bf94acc1a
commit b465900344
9 changed files with 136 additions and 157 deletions

View File

@ -197,7 +197,6 @@ Configuration of stock item options
| Name | Description | Default | Units | | Name | Description | Default | Units |
| ---- | ----------- | ------- | ----- | | ---- | ----------- | ------- | ----- |
{{ globalsetting("SERIAL_NUMBER_GLOBALLY_UNIQUE") }} {{ globalsetting("SERIAL_NUMBER_GLOBALLY_UNIQUE") }}
{{ globalsetting("SERIAL_NUMBER_AUTOFILL") }}
{{ globalsetting("STOCK_DELETE_DEPLETED_DEFAULT") }} {{ globalsetting("STOCK_DELETE_DEPLETED_DEFAULT") }}
{{ globalsetting("STOCK_BATCH_CODE_TEMPLATE") }} {{ globalsetting("STOCK_BATCH_CODE_TEMPLATE") }}
{{ globalsetting("STOCK_ENABLE_EXPIRY") }} {{ globalsetting("STOCK_ENABLE_EXPIRY") }}

View File

@ -640,12 +640,6 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False, 'default': False,
'validator': bool, 'validator': bool,
}, },
'SERIAL_NUMBER_AUTOFILL': {
'name': _('Autofill Serial Numbers'),
'description': _('Autofill serial numbers in forms'),
'default': False,
'validator': bool,
},
'STOCK_DELETE_DEPLETED_DEFAULT': { 'STOCK_DELETE_DEPLETED_DEFAULT': {
'name': _('Delete Depleted Stock'), 'name': _('Delete Depleted Stock'),
'description': _('Determines default behavior when a stock item is depleted'), 'description': _('Determines default behavior when a stock item is depleted'),

View File

@ -25,8 +25,10 @@ import {
import { ProgressBar } from '../components/items/ProgressBar'; import { ProgressBar } from '../components/items/ProgressBar';
import { StatusRenderer } from '../components/render/StatusRenderer'; import { StatusRenderer } from '../components/render/StatusRenderer';
import { useCreateApiFormModal } from '../hooks/UseForm'; import { useCreateApiFormModal } from '../hooks/UseForm';
import { useBatchCodeGenerator } from '../hooks/UseGenerator'; import {
import { useSerialNumberPlaceholder } from '../hooks/UsePlaceholder'; useBatchCodeGenerator,
useSerialNumberGenerator
} from '../hooks/UseGenerator';
import { useGlobalSettingsState } from '../states/SettingsState'; import { useGlobalSettingsState } from '../states/SettingsState';
import { PartColumn } from '../tables/ColumnRenderers'; import { PartColumn } from '../tables/ColumnRenderers';
@ -44,9 +46,9 @@ export function useBuildOrderFields({
const [batchCode, setBatchCode] = useState<string>(''); const [batchCode, setBatchCode] = useState<string>('');
const batchGenerator = useBatchCodeGenerator((value: any) => { const batchGenerator = useBatchCodeGenerator({
if (!batchCode) { onGenerate: (value: any) => {
setBatchCode(value); setBatchCode((batch: any) => batch || value);
} }
}); });
@ -96,6 +98,9 @@ export function useBuildOrderFields({
icon: <IconTruckDelivery /> icon: <IconTruckDelivery />
}, },
batch: { batch: {
placeholder:
batchGenerator.result &&
`${t`Next batch code`}: ${batchGenerator.result}`,
value: batchCode, value: batchCode,
onValueChange: (value: any) => setBatchCode(value) onValueChange: (value: any) => setBatchCode(value)
}, },
@ -143,7 +148,7 @@ export function useBuildOrderFields({
} }
return fields; return fields;
}, [create, destination, batchCode, globalSettings]); }, [create, destination, batchCode, batchGenerator.result, globalSettings]);
} }
export function useBuildOrderOutputFields({ export function useBuildOrderOutputFields({
@ -170,10 +175,17 @@ export function useBuildOrderOutputFields({
setQuantity(Math.max(0, build_quantity - build_complete)); setQuantity(Math.max(0, build_quantity - build_complete));
}, [build]); }, [build]);
const serialPlaceholder = useSerialNumberPlaceholder({ const serialGenerator = useSerialNumberGenerator({
partId: build.part_detail?.pk, initialQuery: {
key: 'build-output', part: build.part || build.part_detail?.pk
enabled: build.part_detail?.trackable }
});
const batchGenerator = useBatchCodeGenerator({
initialQuery: {
part: build.part || build.part_detail?.pk,
quantity: build.quantity
}
}); });
return useMemo(() => { return useMemo(() => {
@ -186,9 +198,15 @@ export function useBuildOrderOutputFields({
}, },
serial_numbers: { serial_numbers: {
hidden: !trackable, hidden: !trackable,
placeholder: serialPlaceholder placeholder:
serialGenerator.result &&
`${t`Next serial number`}: ${serialGenerator.result}`
},
batch_code: {
placeholder:
batchGenerator.result &&
`${t`Next batch code`}: ${batchGenerator.result}`
}, },
batch_code: {},
location: { location: {
value: location, value: location,
onValueChange: (value: any) => { onValueChange: (value: any) => {
@ -199,7 +217,7 @@ export function useBuildOrderOutputFields({
hidden: !trackable hidden: !trackable
} }
}; };
}, [quantity, serialPlaceholder, trackable]); }, [quantity, batchGenerator.result, serialGenerator.result, trackable]);
} }
function BuildOutputFormRow({ function BuildOutputFormRow({

View File

@ -295,16 +295,20 @@ function LineItemFormRow({
}, [record.destination]); }, [record.destination]);
// Batch code generator // Batch code generator
const batchCodeGenerator = useBatchCodeGenerator((value: any) => { const batchCodeGenerator = useBatchCodeGenerator({
if (value) { onGenerate: (value: any) => {
props.changeFn(props.idx, 'batch_code', value); if (value) {
props.changeFn(props.idx, 'batch_code', value);
}
} }
}); });
// Serial number generator // Serial number generator
const serialNumberGenerator = useSerialNumberGenerator((value: any) => { const serialNumberGenerator = useSerialNumberGenerator({
if (value) { onGenerate: (value: any) => {
props.changeFn(props.idx, 'serial_numbers', value); if (value) {
props.changeFn(props.idx, 'serial_numbers', value);
}
} }
}); });
@ -478,8 +482,10 @@ function LineItemFormRow({
fieldDefinition={{ fieldDefinition={{
field_type: 'number', field_type: 'number',
value: props.item.quantity, value: props.item.quantity,
onValueChange: (value) => onValueChange: (value) => {
props.changeFn(props.idx, 'quantity', value) props.changeFn(props.idx, 'quantity', value);
serialNumberGenerator.update({ quantity: value });
}
}} }}
error={props.rowErrors?.quantity?.message} error={props.rowErrors?.quantity?.message}
/> />

View File

@ -56,7 +56,6 @@ import {
useBatchCodeGenerator, useBatchCodeGenerator,
useSerialNumberGenerator useSerialNumberGenerator
} from '../hooks/UseGenerator'; } from '../hooks/UseGenerator';
import { useSerialNumberPlaceholder } from '../hooks/UsePlaceholder';
import { useGlobalSettingsState } from '../states/SettingsState'; import { useGlobalSettingsState } from '../states/SettingsState';
import { StatusFilterOptions } from '../tables/Filter'; import { StatusFilterOptions } from '../tables/Filter';
@ -79,35 +78,20 @@ export function useStockFields({
const [supplierPart, setSupplierPart] = useState<number | null>(null); const [supplierPart, setSupplierPart] = useState<number | null>(null);
const [nextBatchCode, setNextBatchCode] = useState<string>('');
const [nextSerialNumber, setNextSerialNumber] = useState<string>('');
const [expiryDate, setExpiryDate] = useState<string | null>(null); const [expiryDate, setExpiryDate] = useState<string | null>(null);
const batchGenerator = useBatchCodeGenerator((value: any) => { const batchGenerator = useBatchCodeGenerator({
if (value) { initialQuery: {
setNextBatchCode(`${t`Next batch code`}: ${value}`); part: partInstance?.pk || partId
} else {
setNextBatchCode('');
} }
}); });
const serialGenerator = useSerialNumberGenerator((value: any) => { const serialGenerator = useSerialNumberGenerator({
if (value) { initialQuery: {
setNextSerialNumber(`${t`Next serial number`}: ${value}`); part: partInstance?.pk || partId
} else {
setNextSerialNumber('');
} }
}); });
useEffect(() => {
if (partInstance?.pk) {
// Update the generators whenever the part ID changes
batchGenerator.update({ part: partInstance.pk });
serialGenerator.update({ part: partInstance.pk });
}
}, [partInstance.pk]);
return useMemo(() => { return useMemo(() => {
const fields: ApiFormFieldSet = { const fields: ApiFormFieldSet = {
part: { part: {
@ -181,16 +165,23 @@ export function useStockFields({
description: t`Enter serial numbers for new stock (or leave blank)`, description: t`Enter serial numbers for new stock (or leave blank)`,
required: false, required: false,
hidden: !create, hidden: !create,
placeholder: nextSerialNumber placeholder:
serialGenerator.result &&
`${t`Next serial number`}: ${serialGenerator.result}`
}, },
serial: { serial: {
placeholder:
serialGenerator.result &&
`${t`Next serial number`}: ${serialGenerator.result}`,
hidden: hidden:
create || create ||
partInstance.trackable == false || partInstance.trackable == false ||
(stockItem?.quantity != undefined && stockItem?.quantity != 1) (stockItem?.quantity != undefined && stockItem?.quantity != 1)
}, },
batch: { batch: {
placeholder: nextBatchCode placeholder:
batchGenerator.result &&
`${t`Next batch code`}: ${batchGenerator.result}`
}, },
status_custom_key: { status_custom_key: {
label: t`Stock Status` label: t`Stock Status`
@ -234,8 +225,8 @@ export function useStockFields({
partId, partId,
globalSettings, globalSettings,
supplierPart, supplierPart,
nextSerialNumber, serialGenerator.result,
nextBatchCode, batchGenerator.result,
create create
]); ]);
} }
@ -332,21 +323,23 @@ export function useStockItemSerializeFields({
partId: number; partId: number;
trackable: boolean; trackable: boolean;
}) { }) {
const snPlaceholder = useSerialNumberPlaceholder({ const serialGenerator = useSerialNumberGenerator({
partId: partId, initialQuery: {
key: 'stock-item-serialize', part: partId
enabled: trackable }
}); });
return useMemo(() => { return useMemo(() => {
return { return {
quantity: {}, quantity: {},
serial_numbers: { serial_numbers: {
placeholder: snPlaceholder placeholder:
serialGenerator.result &&
`${t`Next serial number`}: ${serialGenerator.result}`
}, },
destination: {} destination: {}
}; };
}, [snPlaceholder]); }, [serialGenerator.result]);
} }
function StockItemDefaultMove({ function StockItemDefaultMove({

View File

@ -6,6 +6,13 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { api } from '../App'; import { api } from '../App';
export type GeneratorProps = {
endpoint: ApiEndpoints;
key: string;
initialQuery?: Record<string, any>;
onGenerate?: (value: any) => void;
};
export type GeneratorState = { export type GeneratorState = {
query: Record<string, any>; query: Record<string, any>;
result: any; result: any;
@ -17,11 +24,7 @@ export type GeneratorState = {
* We can pass additional parameters to the query, and update the query as needed. * We can pass additional parameters to the query, and update the query as needed.
* Each update calls a new query to the API, and the result is stored in the state. * Each update calls a new query to the API, and the result is stored in the state.
*/ */
export function useGenerator( export function useGenerator(props: GeneratorProps): GeneratorState {
endpoint: ApiEndpoints,
key: string,
onGenerate?: (value: any) => void
): GeneratorState {
// Track the result // Track the result
const [result, setResult] = useState<any>(null); const [result, setResult] = useState<any>(null);
@ -29,7 +32,7 @@ export function useGenerator(
const [query, setQuery] = useState<Record<string, any>>({}); const [query, setQuery] = useState<Record<string, any>>({});
// Prevent rapid updates // Prevent rapid updates
const [debouncedQuery] = useDebouncedValue<Record<string, any>>(query, 250); const [debouncedQuery] = useDebouncedValue<Record<string, any>>(query, 100);
// Callback to update the generator query // Callback to update the generator query
const update = useCallback( const update = useCallback(
@ -42,27 +45,44 @@ export function useGenerator(
...params ...params
})); }));
} }
queryGenerator.refetch();
}, },
[] []
); );
// API query handler // API query handler
const queryGenerator = useQuery({ const queryGenerator = useQuery({
enabled: false, enabled: true,
queryKey: ['generator', key, endpoint, debouncedQuery], queryKey: [
'generator',
props.key,
props.endpoint,
props.initialQuery,
debouncedQuery
],
refetchOnMount: false,
refetchOnWindowFocus: false,
queryFn: async () => { queryFn: async () => {
return api.post(apiUrl(endpoint), debouncedQuery).then((response) => { const generatorQuery = {
const value = response?.data[key]; ...debouncedQuery,
setResult(value); ...(props.initialQuery ?? {})
};
if (onGenerate) { return api
onGenerate(value); .post(apiUrl(props.endpoint), generatorQuery)
} .then((response) => {
const value = response?.data[props.key];
setResult(value);
return response; props.onGenerate?.(value);
});
return response;
})
.catch((error) => {
console.error(
`Error generating ${props.key} @ ${props.endpoint}:`,
error
);
});
} }
}); });
@ -74,19 +94,33 @@ export function useGenerator(
} }
// Generate a batch code with provided data // Generate a batch code with provided data
export function useBatchCodeGenerator(onGenerate: (value: any) => void) { export function useBatchCodeGenerator({
return useGenerator( initialQuery,
ApiEndpoints.generate_batch_code, onGenerate
'batch_code', }: {
onGenerate initialQuery?: Record<string, any>;
); onGenerate?: (value: any) => void;
}): GeneratorState {
return useGenerator({
endpoint: ApiEndpoints.generate_batch_code,
key: 'batch_code',
initialQuery: initialQuery,
onGenerate: onGenerate
});
} }
// Generate a serial number with provided data // Generate a serial number with provided data
export function useSerialNumberGenerator(onGenerate: (value: any) => void) { export function useSerialNumberGenerator({
return useGenerator( initialQuery,
ApiEndpoints.generate_serial_number, onGenerate
'serial_number', }: {
onGenerate initialQuery?: Record<string, any>;
); onGenerate?: (value: any) => void;
}): GeneratorState {
return useGenerator({
endpoint: ApiEndpoints.generate_serial_number,
key: 'serial_number',
initialQuery: initialQuery,
onGenerate: onGenerate
});
} }

View File

@ -1,66 +0,0 @@
import { t } from '@lingui/core/macro';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import { api } from '../App';
/**
* Hook for generating a placeholder text for a serial number input
*
* This hook fetches the latest serial number information for a given part and generates a placeholder string.
*
* @param partId The ID of the part to fetch serial number information for
* @param key A unique key to identify the query
* @param enabled Whether the query should be enabled
*/
export function useSerialNumberPlaceholder({
partId,
key,
enabled = true
}: {
partId: number;
key: string;
enabled?: boolean;
}): string | undefined {
// Fetch serial number information (if available)
const snQuery = useQuery({
queryKey: ['serial-placeholder', key, partId],
enabled: enabled ?? true,
queryFn: async () => {
if (!partId) {
return null;
}
const url = apiUrl(ApiEndpoints.part_serial_numbers, partId);
return api
.get(url)
.then((response) => {
if (response.status === 200) {
return response.data;
} else {
return null;
}
})
.catch(() => {
return null;
});
}
});
const placeholder = useMemo(() => {
if (!enabled) {
return undefined;
} else if (snQuery.data?.next) {
return `${t`Next serial number`}: ${snQuery.data.next}`;
} else if (snQuery.data?.latest) {
return `${t`Latest serial number`}: ${snQuery.data.latest}`;
} else {
return undefined;
}
}, [enabled, snQuery.data]);
return placeholder;
}

View File

@ -222,7 +222,6 @@ export default function SystemSettings() {
<GlobalSettingList <GlobalSettingList
keys={[ keys={[
'SERIAL_NUMBER_GLOBALLY_UNIQUE', 'SERIAL_NUMBER_GLOBALLY_UNIQUE',
'SERIAL_NUMBER_AUTOFILL',
'STOCK_DELETE_DEPLETED_DEFAULT', 'STOCK_DELETE_DEPLETED_DEFAULT',
'STOCK_BATCH_CODE_TEMPLATE', 'STOCK_BATCH_CODE_TEMPLATE',
'STOCK_ENABLE_EXPIRY', 'STOCK_ENABLE_EXPIRY',

View File

@ -167,6 +167,8 @@ test('Build Order - Build Outputs', async ({ browser }) => {
.getByLabel('text-field-serial_numbers') .getByLabel('text-field-serial_numbers')
.getAttribute('placeholder'); .getAttribute('placeholder');
expect(placeholder).toContain('Next serial number');
let sn = 1; let sn = 1;
if (!!placeholder && placeholder.includes('Next serial number')) { if (!!placeholder && placeholder.includes('Next serial number')) {