From b465900344b5b48f6b1389a7ca674a64f69fecff Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Fri, 13 Jun 2025 20:02:30 +1000
Subject: [PATCH] [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
---
 docs/docs/settings/global.md                  |  1 -
 .../InvenTree/common/setting/system.py        |  6 --
 src/frontend/src/forms/BuildForms.tsx         | 44 ++++++---
 src/frontend/src/forms/PurchaseOrderForms.tsx | 22 +++--
 src/frontend/src/forms/StockForms.tsx         | 57 +++++------
 src/frontend/src/hooks/UseGenerator.tsx       | 94 +++++++++++++------
 src/frontend/src/hooks/UsePlaceholder.tsx     | 66 -------------
 .../pages/Index/Settings/SystemSettings.tsx   |  1 -
 src/frontend/tests/pages/pui_build.spec.ts    |  2 +
 9 files changed, 136 insertions(+), 157 deletions(-)
 delete mode 100644 src/frontend/src/hooks/UsePlaceholder.tsx

diff --git a/docs/docs/settings/global.md b/docs/docs/settings/global.md
index 9b58e2c48b..57700c61bf 100644
--- a/docs/docs/settings/global.md
+++ b/docs/docs/settings/global.md
@@ -197,7 +197,6 @@ Configuration of stock item options
 | Name | Description | Default | Units |
 | ---- | ----------- | ------- | ----- |
 {{ globalsetting("SERIAL_NUMBER_GLOBALLY_UNIQUE") }}
-{{ globalsetting("SERIAL_NUMBER_AUTOFILL") }}
 {{ globalsetting("STOCK_DELETE_DEPLETED_DEFAULT") }}
 {{ globalsetting("STOCK_BATCH_CODE_TEMPLATE") }}
 {{ globalsetting("STOCK_ENABLE_EXPIRY") }}
diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py
index 2a20685afc..c823a96184 100644
--- a/src/backend/InvenTree/common/setting/system.py
+++ b/src/backend/InvenTree/common/setting/system.py
@@ -640,12 +640,6 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
         'default': False,
         'validator': bool,
     },
-    'SERIAL_NUMBER_AUTOFILL': {
-        'name': _('Autofill Serial Numbers'),
-        'description': _('Autofill serial numbers in forms'),
-        'default': False,
-        'validator': bool,
-    },
     'STOCK_DELETE_DEPLETED_DEFAULT': {
         'name': _('Delete Depleted Stock'),
         'description': _('Determines default behavior when a stock item is depleted'),
diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx
index 0ff3241a09..9c32092058 100644
--- a/src/frontend/src/forms/BuildForms.tsx
+++ b/src/frontend/src/forms/BuildForms.tsx
@@ -25,8 +25,10 @@ import {
 import { ProgressBar } from '../components/items/ProgressBar';
 import { StatusRenderer } from '../components/render/StatusRenderer';
 import { useCreateApiFormModal } from '../hooks/UseForm';
-import { useBatchCodeGenerator } from '../hooks/UseGenerator';
-import { useSerialNumberPlaceholder } from '../hooks/UsePlaceholder';
+import {
+  useBatchCodeGenerator,
+  useSerialNumberGenerator
+} from '../hooks/UseGenerator';
 import { useGlobalSettingsState } from '../states/SettingsState';
 import { PartColumn } from '../tables/ColumnRenderers';
 
@@ -44,9 +46,9 @@ export function useBuildOrderFields({
 
   const [batchCode, setBatchCode] = useState<string>('');
 
-  const batchGenerator = useBatchCodeGenerator((value: any) => {
-    if (!batchCode) {
-      setBatchCode(value);
+  const batchGenerator = useBatchCodeGenerator({
+    onGenerate: (value: any) => {
+      setBatchCode((batch: any) => batch || value);
     }
   });
 
@@ -96,6 +98,9 @@ export function useBuildOrderFields({
         icon: <IconTruckDelivery />
       },
       batch: {
+        placeholder:
+          batchGenerator.result &&
+          `${t`Next batch code`}: ${batchGenerator.result}`,
         value: batchCode,
         onValueChange: (value: any) => setBatchCode(value)
       },
@@ -143,7 +148,7 @@ export function useBuildOrderFields({
     }
 
     return fields;
-  }, [create, destination, batchCode, globalSettings]);
+  }, [create, destination, batchCode, batchGenerator.result, globalSettings]);
 }
 
 export function useBuildOrderOutputFields({
@@ -170,10 +175,17 @@ export function useBuildOrderOutputFields({
     setQuantity(Math.max(0, build_quantity - build_complete));
   }, [build]);
 
-  const serialPlaceholder = useSerialNumberPlaceholder({
-    partId: build.part_detail?.pk,
-    key: 'build-output',
-    enabled: build.part_detail?.trackable
+  const serialGenerator = useSerialNumberGenerator({
+    initialQuery: {
+      part: build.part || build.part_detail?.pk
+    }
+  });
+
+  const batchGenerator = useBatchCodeGenerator({
+    initialQuery: {
+      part: build.part || build.part_detail?.pk,
+      quantity: build.quantity
+    }
   });
 
   return useMemo(() => {
@@ -186,9 +198,15 @@ export function useBuildOrderOutputFields({
       },
       serial_numbers: {
         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: {
         value: location,
         onValueChange: (value: any) => {
@@ -199,7 +217,7 @@ export function useBuildOrderOutputFields({
         hidden: !trackable
       }
     };
-  }, [quantity, serialPlaceholder, trackable]);
+  }, [quantity, batchGenerator.result, serialGenerator.result, trackable]);
 }
 
 function BuildOutputFormRow({
diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx
index ca1a9e2452..f95e762089 100644
--- a/src/frontend/src/forms/PurchaseOrderForms.tsx
+++ b/src/frontend/src/forms/PurchaseOrderForms.tsx
@@ -295,16 +295,20 @@ function LineItemFormRow({
   }, [record.destination]);
 
   // Batch code generator
-  const batchCodeGenerator = useBatchCodeGenerator((value: any) => {
-    if (value) {
-      props.changeFn(props.idx, 'batch_code', value);
+  const batchCodeGenerator = useBatchCodeGenerator({
+    onGenerate: (value: any) => {
+      if (value) {
+        props.changeFn(props.idx, 'batch_code', value);
+      }
     }
   });
 
   // Serial number generator
-  const serialNumberGenerator = useSerialNumberGenerator((value: any) => {
-    if (value) {
-      props.changeFn(props.idx, 'serial_numbers', value);
+  const serialNumberGenerator = useSerialNumberGenerator({
+    onGenerate: (value: any) => {
+      if (value) {
+        props.changeFn(props.idx, 'serial_numbers', value);
+      }
     }
   });
 
@@ -478,8 +482,10 @@ function LineItemFormRow({
             fieldDefinition={{
               field_type: 'number',
               value: props.item.quantity,
-              onValueChange: (value) =>
-                props.changeFn(props.idx, 'quantity', value)
+              onValueChange: (value) => {
+                props.changeFn(props.idx, 'quantity', value);
+                serialNumberGenerator.update({ quantity: value });
+              }
             }}
             error={props.rowErrors?.quantity?.message}
           />
diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx
index 1f2f5c5e2b..d4c6cb0317 100644
--- a/src/frontend/src/forms/StockForms.tsx
+++ b/src/frontend/src/forms/StockForms.tsx
@@ -56,7 +56,6 @@ import {
   useBatchCodeGenerator,
   useSerialNumberGenerator
 } from '../hooks/UseGenerator';
-import { useSerialNumberPlaceholder } from '../hooks/UsePlaceholder';
 import { useGlobalSettingsState } from '../states/SettingsState';
 import { StatusFilterOptions } from '../tables/Filter';
 
@@ -79,35 +78,20 @@ export function useStockFields({
 
   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 batchGenerator = useBatchCodeGenerator((value: any) => {
-    if (value) {
-      setNextBatchCode(`${t`Next batch code`}: ${value}`);
-    } else {
-      setNextBatchCode('');
+  const batchGenerator = useBatchCodeGenerator({
+    initialQuery: {
+      part: partInstance?.pk || partId
     }
   });
 
-  const serialGenerator = useSerialNumberGenerator((value: any) => {
-    if (value) {
-      setNextSerialNumber(`${t`Next serial number`}: ${value}`);
-    } else {
-      setNextSerialNumber('');
+  const serialGenerator = useSerialNumberGenerator({
+    initialQuery: {
+      part: partInstance?.pk || partId
     }
   });
 
-  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(() => {
     const fields: ApiFormFieldSet = {
       part: {
@@ -181,16 +165,23 @@ export function useStockFields({
         description: t`Enter serial numbers for new stock (or leave blank)`,
         required: false,
         hidden: !create,
-        placeholder: nextSerialNumber
+        placeholder:
+          serialGenerator.result &&
+          `${t`Next serial number`}: ${serialGenerator.result}`
       },
       serial: {
+        placeholder:
+          serialGenerator.result &&
+          `${t`Next serial number`}: ${serialGenerator.result}`,
         hidden:
           create ||
           partInstance.trackable == false ||
           (stockItem?.quantity != undefined && stockItem?.quantity != 1)
       },
       batch: {
-        placeholder: nextBatchCode
+        placeholder:
+          batchGenerator.result &&
+          `${t`Next batch code`}: ${batchGenerator.result}`
       },
       status_custom_key: {
         label: t`Stock Status`
@@ -234,8 +225,8 @@ export function useStockFields({
     partId,
     globalSettings,
     supplierPart,
-    nextSerialNumber,
-    nextBatchCode,
+    serialGenerator.result,
+    batchGenerator.result,
     create
   ]);
 }
@@ -332,21 +323,23 @@ export function useStockItemSerializeFields({
   partId: number;
   trackable: boolean;
 }) {
-  const snPlaceholder = useSerialNumberPlaceholder({
-    partId: partId,
-    key: 'stock-item-serialize',
-    enabled: trackable
+  const serialGenerator = useSerialNumberGenerator({
+    initialQuery: {
+      part: partId
+    }
   });
 
   return useMemo(() => {
     return {
       quantity: {},
       serial_numbers: {
-        placeholder: snPlaceholder
+        placeholder:
+          serialGenerator.result &&
+          `${t`Next serial number`}: ${serialGenerator.result}`
       },
       destination: {}
     };
-  }, [snPlaceholder]);
+  }, [serialGenerator.result]);
 }
 
 function StockItemDefaultMove({
diff --git a/src/frontend/src/hooks/UseGenerator.tsx b/src/frontend/src/hooks/UseGenerator.tsx
index aaaca15862..c923a2c0e1 100644
--- a/src/frontend/src/hooks/UseGenerator.tsx
+++ b/src/frontend/src/hooks/UseGenerator.tsx
@@ -6,6 +6,13 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
 import { apiUrl } from '@lib/functions/Api';
 import { api } from '../App';
 
+export type GeneratorProps = {
+  endpoint: ApiEndpoints;
+  key: string;
+  initialQuery?: Record<string, any>;
+  onGenerate?: (value: any) => void;
+};
+
 export type GeneratorState = {
   query: Record<string, any>;
   result: any;
@@ -17,11 +24,7 @@ export type GeneratorState = {
  * 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.
  */
-export function useGenerator(
-  endpoint: ApiEndpoints,
-  key: string,
-  onGenerate?: (value: any) => void
-): GeneratorState {
+export function useGenerator(props: GeneratorProps): GeneratorState {
   // Track the result
   const [result, setResult] = useState<any>(null);
 
@@ -29,7 +32,7 @@ export function useGenerator(
   const [query, setQuery] = useState<Record<string, any>>({});
 
   // 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
   const update = useCallback(
@@ -42,27 +45,44 @@ export function useGenerator(
           ...params
         }));
       }
-
-      queryGenerator.refetch();
     },
     []
   );
 
   // API query handler
   const queryGenerator = useQuery({
-    enabled: false,
-    queryKey: ['generator', key, endpoint, debouncedQuery],
+    enabled: true,
+    queryKey: [
+      'generator',
+      props.key,
+      props.endpoint,
+      props.initialQuery,
+      debouncedQuery
+    ],
+    refetchOnMount: false,
+    refetchOnWindowFocus: false,
     queryFn: async () => {
-      return api.post(apiUrl(endpoint), debouncedQuery).then((response) => {
-        const value = response?.data[key];
-        setResult(value);
+      const generatorQuery = {
+        ...debouncedQuery,
+        ...(props.initialQuery ?? {})
+      };
 
-        if (onGenerate) {
-          onGenerate(value);
-        }
+      return api
+        .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
-export function useBatchCodeGenerator(onGenerate: (value: any) => void) {
-  return useGenerator(
-    ApiEndpoints.generate_batch_code,
-    'batch_code',
-    onGenerate
-  );
+export function useBatchCodeGenerator({
+  initialQuery,
+  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
-export function useSerialNumberGenerator(onGenerate: (value: any) => void) {
-  return useGenerator(
-    ApiEndpoints.generate_serial_number,
-    'serial_number',
-    onGenerate
-  );
+export function useSerialNumberGenerator({
+  initialQuery,
+  onGenerate
+}: {
+  initialQuery?: Record<string, any>;
+  onGenerate?: (value: any) => void;
+}): GeneratorState {
+  return useGenerator({
+    endpoint: ApiEndpoints.generate_serial_number,
+    key: 'serial_number',
+    initialQuery: initialQuery,
+    onGenerate: onGenerate
+  });
 }
diff --git a/src/frontend/src/hooks/UsePlaceholder.tsx b/src/frontend/src/hooks/UsePlaceholder.tsx
deleted file mode 100644
index 3a655862c4..0000000000
--- a/src/frontend/src/hooks/UsePlaceholder.tsx
+++ /dev/null
@@ -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;
-}
diff --git a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
index b268ec65dd..4d1ed7a2bd 100644
--- a/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
+++ b/src/frontend/src/pages/Index/Settings/SystemSettings.tsx
@@ -222,7 +222,6 @@ export default function SystemSettings() {
           <GlobalSettingList
             keys={[
               'SERIAL_NUMBER_GLOBALLY_UNIQUE',
-              'SERIAL_NUMBER_AUTOFILL',
               'STOCK_DELETE_DEPLETED_DEFAULT',
               'STOCK_BATCH_CODE_TEMPLATE',
               'STOCK_ENABLE_EXPIRY',
diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts
index a7c7134c99..17f7e2a687 100644
--- a/src/frontend/tests/pages/pui_build.spec.ts
+++ b/src/frontend/tests/pages/pui_build.spec.ts
@@ -167,6 +167,8 @@ test('Build Order - Build Outputs', async ({ browser }) => {
     .getByLabel('text-field-serial_numbers')
     .getAttribute('placeholder');
 
+  expect(placeholder).toContain('Next serial number');
+
   let sn = 1;
 
   if (!!placeholder && placeholder.includes('Next serial number')) {