From e93d9c4a744af4caafc1b5b901491828fb8d409b Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 20 May 2024 23:56:45 +1000 Subject: [PATCH] Batch code generation (#7000) * Refactor framework for generating batch codes - Provide additional kwargs to plugin - Move into new file - Error handling * Implement API endpoint for generating a new batch code * Fixes * Refactor into stock.generators * Fix API endpoint * Pass time context through to plugins * Generate batch code when receiving items * Create useGenerator hook - Build up a dataset and query server whenever it changes - Look for result in response data - For now, just used for generating batch codes - may be used for more in the future * Refactor PurchaseOrderForms to use new generator hook * Refactor StockForms implementation * Remove dead code * add OAS diff * fix ref * fix ref again * wrong branch, sorry * Update src/frontend/src/hooks/UseGenerator.tsx Co-authored-by: Lukas <76838159+wolflu05@users.noreply.github.com> * Bump API version * Do not override batch code if already generated * Add serial number generator - Move to /generate/ API endpoint - Move batch code generator too * Update PUI endpoints * Add debouncing to useGenerator hook * Refactor useGenerator func * Add serial number generator to stock form * Add batch code genereator to build order form * Update buildfields * Use build batch code when creating new output --------- Co-authored-by: Matthias Mair Co-authored-by: Lukas <76838159+wolflu05@users.noreply.github.com> --- docs/docs/extend/plugins/validation.md | 2 +- src/backend/InvenTree/InvenTree/api.py | 1 + .../InvenTree/InvenTree/api_version.py | 7 +- src/backend/InvenTree/InvenTree/helpers.py | 4 +- src/backend/InvenTree/InvenTree/urls.py | 15 ++ src/backend/InvenTree/build/serializers.py | 5 +- .../base/integration/ValidationMixin.py | 7 +- .../samples/integration/validation_sample.py | 14 +- src/backend/InvenTree/stock/api.py | 37 ++++- src/backend/InvenTree/stock/generators.py | 113 +++++++++++++++ .../migrations/0074_alter_stockitem_batch.py | 3 +- src/backend/InvenTree/stock/models.py | 43 +----- src/backend/InvenTree/stock/serializers.py | 129 ++++++++++++++++++ src/frontend/src/enums/ApiEndpoints.tsx | 4 + src/frontend/src/forms/BuildForms.tsx | 21 ++- src/frontend/src/forms/PurchaseOrderForms.tsx | 14 ++ src/frontend/src/forms/StockForms.tsx | 57 ++++++-- src/frontend/src/functions/icons.tsx | 1 + src/frontend/src/hooks/UseGenerator.tsx | 90 ++++++++++++ src/frontend/src/pages/build/BuildDetail.tsx | 7 + .../src/tables/build/BuildOutputTable.tsx | 3 + 21 files changed, 513 insertions(+), 64 deletions(-) create mode 100644 src/backend/InvenTree/stock/generators.py create mode 100644 src/frontend/src/hooks/UseGenerator.tsx diff --git a/docs/docs/extend/plugins/validation.md b/docs/docs/extend/plugins/validation.md index a200ab2416..46b0b89474 100644 --- a/docs/docs/extend/plugins/validation.md +++ b/docs/docs/extend/plugins/validation.md @@ -116,7 +116,7 @@ Validation of the Part IPN (Internal Part Number) field is exposed to custom plu The `validate_batch_code` method allows plugins to raise an error if a batch code input by the user does not meet a particular pattern. -The `generate_batch_code` method can be implemented to generate a new batch code. +The `generate_batch_code` method can be implemented to generate a new batch code, based on a set of provided information. ### Serial Numbers diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index b220e09135..c3c0db4af2 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -8,6 +8,7 @@ from pathlib import Path from django.conf import settings from django.db import transaction from django.http import JsonResponse +from django.urls import include, path from django.utils.translation import gettext_lazy as _ from django_q.models import OrmQ diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 0026ec5651..d4bdd7f537 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 199 +INVENTREE_API_VERSION = 200 + """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v200 - 2024-05-20 : https://github.com/inventree/InvenTree/pull/7000 + - Adds API endpoint for generating custom batch codes + - Adds API endpoint for generating custom serial numbers + v199 - 2024-05-20 : https://github.com/inventree/InvenTree/pull/7264 - Expose "bom_valid" filter for the Part API - Expose "starred" filter for the Part API diff --git a/src/backend/InvenTree/InvenTree/helpers.py b/src/backend/InvenTree/InvenTree/helpers.py index fab133036b..54b2e9ee39 100644 --- a/src/backend/InvenTree/InvenTree/helpers.py +++ b/src/backend/InvenTree/InvenTree/helpers.py @@ -293,13 +293,13 @@ def increment(value): QQQ -> QQQ """ - value = str(value).strip() - # Ignore empty strings if value in ['', None]: # Provide a default value if provided with a null input return '1' + value = str(value).strip() + pattern = r'(.*?)(\d+)?$' result = re.search(pattern, value) diff --git a/src/backend/InvenTree/InvenTree/urls.py b/src/backend/InvenTree/InvenTree/urls.py index ab3dc008a2..bd97236ca6 100644 --- a/src/backend/InvenTree/InvenTree/urls.py +++ b/src/backend/InvenTree/InvenTree/urls.py @@ -86,6 +86,21 @@ apipatterns = [ path('part/', include(part.api.part_api_urls)), path('bom/', include(part.api.bom_api_urls)), path('company/', include(company.api.company_api_urls)), + path( + 'generate/', + include([ + path( + 'batch-code/', + stock.api.GenerateBatchCode.as_view(), + name='api-generate-batch-code', + ), + path( + 'serial-number/', + stock.api.GenerateSerialNumber.as_view(), + name='api-generate-serial-number', + ), + ]), + ), path('stock/', include(stock.api.stock_api_urls)), path('build/', include(build.api.build_api_urls)), path('order/', include(order.api.order_api_urls)), diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index b790d9fc82..de6981ca97 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -1,7 +1,5 @@ """JSON serializers for Build API.""" -from decimal import Decimal - from django.db import transaction from django.core.exceptions import ValidationError as DjangoValidationError from django.utils.translation import gettext_lazy as _ @@ -22,7 +20,8 @@ import InvenTree.helpers from InvenTree.serializers import InvenTreeDecimalField from InvenTree.status_codes import StockStatus -from stock.models import generate_batch_code, StockItem, StockLocation +from stock.generators import generate_batch_code +from stock.models import StockItem, StockLocation from stock.serializers import StockItemSerializerBrief, LocationSerializer import common.models diff --git a/src/backend/InvenTree/plugin/base/integration/ValidationMixin.py b/src/backend/InvenTree/plugin/base/integration/ValidationMixin.py index d282de64d0..2dcb6b03e5 100644 --- a/src/backend/InvenTree/plugin/base/integration/ValidationMixin.py +++ b/src/backend/InvenTree/plugin/base/integration/ValidationMixin.py @@ -129,9 +129,14 @@ class ValidationMixin: """ return None - def generate_batch_code(self): + def generate_batch_code(self, **kwargs): """Generate a new batch code. + This method is called when a new batch code is required. + + kwargs: + Any additional keyword arguments which are passed through to the plugin, based on the context of the caller + Returns: A new batch code (string) or None """ diff --git a/src/backend/InvenTree/plugin/samples/integration/validation_sample.py b/src/backend/InvenTree/plugin/samples/integration/validation_sample.py index c1edd4e980..09362b0135 100644 --- a/src/backend/InvenTree/plugin/samples/integration/validation_sample.py +++ b/src/backend/InvenTree/plugin/samples/integration/validation_sample.py @@ -145,7 +145,17 @@ class SampleValidatorPlugin(SettingsMixin, ValidationMixin, InvenTreePlugin): if len(batch_code) > 0 and prefix and not batch_code.startswith(prefix): self.raise_error(f"Batch code must start with '{prefix}'") - def generate_batch_code(self): + def generate_batch_code(self, **kwargs): """Generate a new batch code.""" now = datetime.now() - return f'BATCH-{now.year}:{now.month}:{now.day}' + batch = f'SAMPLE-BATCH-{now.year}:{now.month}:{now.day}' + + # If a Part instance is provided, prepend the part name to the batch code + if part := kwargs.get('part', None): + batch = f'{part.name}-{batch}' + + # If a Build instance is provided, prepend the build number to the batch code + if build := kwargs.get('build_order', None): + batch = f'{build.reference}-{batch}' + + return batch diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index 3c2aaff001..0fdae2d475 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -1,5 +1,6 @@ """JSON API for the Stock app.""" +import json from collections import OrderedDict from datetime import timedelta @@ -13,7 +14,8 @@ from django.utils.translation import gettext_lazy as _ from django_filters import rest_framework as rest_filters from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field -from rest_framework import status +from rest_framework import permissions, status +from rest_framework.generics import GenericAPIView from rest_framework.response import Response from rest_framework.serializers import ValidationError @@ -64,6 +66,7 @@ from order.serializers import ( from part.models import BomItem, Part, PartCategory from part.serializers import PartBriefSerializer from stock.admin import LocationResource, StockItemResource +from stock.generators import generate_batch_code, generate_serial_number from stock.models import ( StockItem, StockItemAttachment, @@ -74,6 +77,38 @@ from stock.models import ( ) +class GenerateBatchCode(GenericAPIView): + """API endpoint for generating batch codes.""" + + permission_classes = [permissions.IsAuthenticated] + serializer_class = StockSerializers.GenerateBatchCodeSerializer + + def post(self, request, *args, **kwargs): + """Generate a new batch code.""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + data = {'batch_code': generate_batch_code(**serializer.validated_data)} + + return Response(data, status=status.HTTP_201_CREATED) + + +class GenerateSerialNumber(GenericAPIView): + """API endpoint for generating serial numbers.""" + + permission_classes = [permissions.IsAuthenticated] + serializer_class = StockSerializers.GenerateSerialNumberSerializer + + def post(self, request, *args, **kwargs): + """Generate a new serial number.""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + data = {'serial_number': generate_serial_number(**serializer.validated_data)} + + return Response(data, status=status.HTTP_201_CREATED) + + class StockDetail(RetrieveUpdateDestroyAPI): """API detail endpoint for Stock object. diff --git a/src/backend/InvenTree/stock/generators.py b/src/backend/InvenTree/stock/generators.py new file mode 100644 index 0000000000..f08ec0ab05 --- /dev/null +++ b/src/backend/InvenTree/stock/generators.py @@ -0,0 +1,113 @@ +"""Generator functions for the stock app.""" + +from inspect import signature + +from django.core.exceptions import ValidationError + +from jinja2 import Template + +import common.models +import InvenTree.exceptions +import InvenTree.helpers + + +def generate_batch_code(**kwargs): + """Generate a default 'batch code' for a new StockItem. + + By default, this uses the value of the 'STOCK_BATCH_CODE_TEMPLATE' setting (if configured), + which can be passed through a simple template. + + Also, this function is exposed to the ValidationMixin plugin class, + allowing custom plugins to be used to generate new batch code values. + + Various kwargs can be passed to the function, which will be passed through to the plugin functions. + """ + # First, check if any plugins can generate batch codes + from plugin.registry import registry + + now = InvenTree.helpers.current_time() + + context = { + 'date': now, + 'year': now.year, + 'month': now.month, + 'day': now.day, + 'hour': now.hour, + 'minute': now.minute, + 'week': now.isocalendar()[1], + **kwargs, + } + + for plugin in registry.with_mixin('validation'): + generate = getattr(plugin, 'generate_batch_code', None) + + if not generate: + continue + + # Check if the function signature accepts kwargs + sig = signature(generate) + + if 'kwargs' in sig.parameters: + # Pass the kwargs through to the plugin + try: + batch = generate(**context) + except Exception: + InvenTree.exceptions.log_error('plugin.generate_batch_code') + continue + else: + # Ignore the kwargs (legacy plugin) + try: + batch = generate() + except Exception: + InvenTree.exceptions.log_error('plugin.generate_batch_code') + continue + + # Return the first non-null value generated by a plugin + if batch is not None: + return batch + + # If we get to this point, no plugin was able to generate a new batch code + batch_template = common.models.InvenTreeSetting.get_setting( + 'STOCK_BATCH_CODE_TEMPLATE', '' + ) + + return Template(batch_template).render(context) + + +def generate_serial_number(part=None, quantity=1, **kwargs) -> str: + """Generate a default 'serial number' for a new StockItem.""" + from plugin.registry import registry + + quantity = quantity or 1 + + if part is None: + # Cannot generate a serial number without a part + return None + + try: + quantity = int(quantity) + except Exception: + raise ValidationError({'quantity': 'Invalid quantity value'}) + + if quantity < 1: + raise ValidationError({'quantity': 'Quantity must be greater than zero'}) + + # If we are here, no plugins were available to generate a serial number + # In this case, we will generate a simple serial number based on the provided part + sn = part.get_latest_serial_number() + + serials = [] + + # Generate the required quantity of serial numbers + # Note that this call gets passed through to the plugin system + while quantity > 0: + sn = InvenTree.helpers.increment_serial_number(sn) + + # Exit if an empty or duplicated serial is generated + if not sn or sn in serials: + break + + serials.append(sn) + quantity -= 1 + + return ','.join(serials) diff --git a/src/backend/InvenTree/stock/migrations/0074_alter_stockitem_batch.py b/src/backend/InvenTree/stock/migrations/0074_alter_stockitem_batch.py index 646e25199a..5faa8e81b3 100644 --- a/src/backend/InvenTree/stock/migrations/0074_alter_stockitem_batch.py +++ b/src/backend/InvenTree/stock/migrations/0074_alter_stockitem_batch.py @@ -1,6 +1,7 @@ # Generated by Django 3.2.12 on 2022-04-26 10:19 from django.db import migrations, models +import stock.generators import stock.models @@ -14,6 +15,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='stockitem', name='batch', - field=models.CharField(blank=True, default=stock.models.generate_batch_code, help_text='Batch code for this stock item', max_length=100, null=True, verbose_name='Batch Code'), + field=models.CharField(blank=True, default=stock.generators.generate_batch_code, help_text='Batch code for this stock item', max_length=100, null=True, verbose_name='Batch Code'), ), ] diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 2bfcbc47a1..ab20bc59aa 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -20,7 +20,6 @@ from django.dispatch import receiver from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from jinja2 import Template from mptt.managers import TreeManager from mptt.models import MPTTModel, TreeForeignKey from taggit.managers import TaggableManager @@ -43,6 +42,7 @@ from InvenTree.status_codes import ( ) from part import models as PartModels from plugin.events import trigger_event +from stock.generators import generate_batch_code from users.models import Owner logger = logging.getLogger('inventree') @@ -295,47 +295,6 @@ class StockLocation( return self.get_stock_items(cascade=cascade) -def generate_batch_code(): - """Generate a default 'batch code' for a new StockItem. - - By default, this uses the value of the 'STOCK_BATCH_CODE_TEMPLATE' setting (if configured), - which can be passed through a simple template. - - Also, this function is exposed to the ValidationMixin plugin class, - allowing custom plugins to be used to generate new batch code values - """ - # First, check if any plugins can generate batch codes - from plugin.registry import registry - - for plugin in registry.with_mixin('validation'): - batch = plugin.generate_batch_code() - - if batch is not None: - # Return the first non-null value generated by a plugin - return batch - - # If we get to this point, no plugin was able to generate a new batch code - batch_template = common.models.InvenTreeSetting.get_setting( - 'STOCK_BATCH_CODE_TEMPLATE', '' - ) - - now = InvenTree.helpers.current_time() - - # Pass context data through to the template rendering. - # The following context variables are available for custom batch code generation - context = { - 'date': now, - 'year': now.year, - 'month': now.month, - 'day': now.day, - 'hour': now.hour, - 'minute': now.minute, - 'week': now.isocalendar()[1], - } - - return Template(batch_template).render(context) - - def default_delete_on_deplete(): """Return a default value for the 'delete_on_deplete' field. diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index a820de9ccc..112647d6ce 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -15,11 +15,13 @@ from rest_framework.serializers import ValidationError from sql_util.utils import SubqueryCount, SubquerySum from taggit.serializers import TagListSerializerField +import build.models import common.models import company.models import InvenTree.helpers import InvenTree.serializers import InvenTree.status_codes +import order.models import part.filters as part_filters import part.models as part_models import stock.filters @@ -39,6 +41,133 @@ from .models import ( logger = logging.getLogger('inventree') +class GenerateBatchCodeSerializer(serializers.Serializer): + """Serializer for generating a batch code. + + Any of the provided write-only fields can be used for additional context. + """ + + class Meta: + """Metaclass options.""" + + fields = [ + 'batch_code', + 'build_order', + 'item', + 'location', + 'part', + 'purchase_order', + 'quantity', + ] + + read_only_fields = ['batch_code'] + + write_only_fields = [ + 'build_order', + 'item', + 'location', + 'part', + 'purchase_order', + 'quantity', + ] + + batch_code = serializers.CharField( + read_only=True, help_text=_('Generated batch code'), label=_('Batch Code') + ) + + build_order = serializers.PrimaryKeyRelatedField( + queryset=build.models.Build.objects.all(), + many=False, + required=False, + allow_null=True, + label=_('Build Order'), + help_text=_('Select build order'), + ) + + item = serializers.PrimaryKeyRelatedField( + queryset=StockItem.objects.all(), + many=False, + required=False, + allow_null=True, + label=_('Stock Item'), + help_text=_('Select stock item to generate batch code for'), + ) + + location = serializers.PrimaryKeyRelatedField( + queryset=StockLocation.objects.all(), + many=False, + required=False, + allow_null=True, + label=_('Location'), + help_text=_('Select location to generate batch code for'), + ) + + part = serializers.PrimaryKeyRelatedField( + queryset=part_models.Part.objects.all(), + many=False, + required=False, + allow_null=True, + label=_('Part'), + help_text=_('Select part to generate batch code for'), + ) + + purchase_order = serializers.PrimaryKeyRelatedField( + queryset=order.models.PurchaseOrder.objects.all(), + many=False, + required=False, + allow_null=True, + label=_('Purchase Order'), + help_text=_('Select purchase order'), + ) + + quantity = serializers.FloatField( + required=False, + allow_null=True, + label=_('Quantity'), + help_text=_('Enter quantity for batch code'), + ) + + +class GenerateSerialNumberSerializer(serializers.Serializer): + """Serializer for generating one or multiple serial numbers. + + Any of the provided write-only fields can be used for additional context. + + Note that in the case where multiple serial numbers are required, + the "serial" field will return a string with multiple serial numbers separated by a comma. + """ + + class Meta: + """Metaclass options.""" + + fields = ['serial', 'part', 'quantity'] + + read_only_fields = ['serial'] + + write_only_fields = ['part', 'quantity'] + + serial = serializers.CharField( + read_only=True, help_text=_('Generated serial number'), label=_('Serial Number') + ) + + part = serializers.PrimaryKeyRelatedField( + queryset=part_models.Part.objects.all(), + many=False, + required=False, + allow_null=True, + label=_('Part'), + help_text=_('Select part to generate serial number for'), + ) + + quantity = serializers.IntegerField( + required=False, + allow_null=False, + default=1, + label=_('Quantity'), + help_text=_('Quantity of serial numbers to generate'), + ) + + class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Provides a brief serializer for a StockLocation object.""" diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 8f703f1624..73272cf08b 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -108,6 +108,10 @@ export enum ApiEndpoints { stock_status = 'stock/status/', stock_install = 'stock/:id/install', + // Generator API endpoints + generate_batch_code = 'generate/batch-code/', + generate_serial_number = 'generate/serial-number/', + // Order API endpoints purchase_order_list = 'order/po/', purchase_order_line_list = 'order/po-line/', diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx index cdba958269..f819f3cc19 100644 --- a/src/frontend/src/forms/BuildForms.tsx +++ b/src/frontend/src/forms/BuildForms.tsx @@ -19,6 +19,7 @@ import { ApiEndpoints } from '../enums/ApiEndpoints'; import { ModelType } from '../enums/ModelType'; import { InvenTreeIcon } from '../functions/icons'; import { useCreateApiFormModal } from '../hooks/UseForm'; +import { useBatchCodeGenerator } from '../hooks/UseGenerator'; import { apiUrl } from '../states/ApiState'; import { PartColumn, StatusColumn } from '../tables/ColumnRenderers'; @@ -34,10 +35,19 @@ export function useBuildOrderFields({ null ); + const [batchCode, setBatchCode] = useState(''); + + const batchGenerator = useBatchCodeGenerator((value: any) => { + if (!batchCode) { + setBatchCode(value); + } + }); + return useMemo(() => { return { reference: {}, part: { + disabled: !create, filters: { assembly: true, virtual: false @@ -49,6 +59,10 @@ export function useBuildOrderFields({ record.default_location || record.category_default_location ); } + + batchGenerator.update({ + part: value + }); } }, title: {}, @@ -66,7 +80,10 @@ export function useBuildOrderFields({ sales_order: { icon: }, - batch: {}, + batch: { + value: batchCode, + onValueChange: (value: any) => setBatchCode(value) + }, target_date: { icon: }, @@ -90,7 +107,7 @@ export function useBuildOrderFields({ } } }; - }, [create, destination]); + }, [create, destination, batchCode]); } export function useBuildOrderOutputFields({ diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index a5ad45d826..e2a82845ea 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -39,6 +39,7 @@ import { ApiEndpoints } from '../enums/ApiEndpoints'; import { ModelType } from '../enums/ModelType'; import { InvenTreeIcon } from '../functions/icons'; import { useCreateApiFormModal } from '../hooks/UseForm'; +import { useBatchCodeGenerator } from '../hooks/UseGenerator'; import { apiUrl } from '../states/ApiState'; /* @@ -212,6 +213,12 @@ function LineItemFormRow({ input.changeFn(input.idx, 'location', location); }, [location]); + const batchCodeGenerator = useBatchCodeGenerator((value: any) => { + if (!batchCode) { + setBatchCode(value); + } + }); + // State for serializing const [batchCode, setBatchCode] = useState(''); const [serials, setSerials] = useState(''); @@ -219,6 +226,13 @@ function LineItemFormRow({ onClose: () => { input.changeFn(input.idx, 'batch_code', ''); input.changeFn(input.idx, 'serial_numbers', ''); + }, + onOpen: () => { + // Generate a new batch code + batchCodeGenerator.update({ + part: record?.supplier_part_detail?.part, + order: record?.order + }); } }); diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index fca1e2834c..c6a944992d 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -21,6 +21,10 @@ import { useCreateApiFormModal, useDeleteApiFormModal } from '../hooks/UseForm'; +import { + useBatchCodeGenerator, + useSerialNumberGenerator +} from '../hooks/UseGenerator'; import { apiUrl } from '../states/ApiState'; /** @@ -34,15 +38,41 @@ export function useStockFields({ const [part, setPart] = useState(null); const [supplierPart, setSupplierPart] = useState(null); + const [batchCode, setBatchCode] = useState(''); + const [serialNumbers, setSerialNumbers] = useState(''); + + const [trackable, setTrackable] = useState(false); + + const batchGenerator = useBatchCodeGenerator((value: any) => { + if (!batchCode) { + setBatchCode(value); + } + }); + + const serialGenerator = useSerialNumberGenerator((value: any) => { + if (!serialNumbers && create && trackable) { + setSerialNumbers(value); + } + }); + return useMemo(() => { const fields: ApiFormFieldSet = { part: { value: part, disabled: !create, - onValueChange: (change) => { - setPart(change); + onValueChange: (value, record) => { + setPart(value); // TODO: implement remaining functionality from old stock.py + setTrackable(record.trackable ?? false); + + batchGenerator.update({ part: value }); + serialGenerator.update({ part: value }); + + if (!record.trackable) { + setSerialNumbers(''); + } + // Clear the 'supplier_part' field if the part is changed setSupplierPart(null); } @@ -50,7 +80,9 @@ export function useStockFields({ supplier_part: { // TODO: icon value: supplierPart, - onValueChange: setSupplierPart, + onValueChange: (value) => { + setSupplierPart(value); + }, filters: { part_detail: true, supplier_detail: true, @@ -70,22 +102,29 @@ export function useStockFields({ }, location: { hidden: !create, + onValueChange: (value) => { + batchGenerator.update({ location: value }); + }, filters: { structural: false } - // TODO: icon }, quantity: { hidden: !create, - description: t`Enter initial quantity for this stock item` + description: t`Enter initial quantity for this stock item`, + onValueChange: (value) => { + batchGenerator.update({ quantity: value }); + } }, serial_numbers: { - // TODO: icon field_type: 'string', label: t`Serial Numbers`, description: t`Enter serial numbers for new stock (or leave blank)`, required: false, - hidden: !create + disabled: !trackable, + hidden: !create, + value: serialNumbers, + onValueChange: (value) => setSerialNumbers(value) }, serial: { hidden: create @@ -93,6 +132,8 @@ export function useStockFields({ }, batch: { // TODO: icon + value: batchCode, + onValueChange: (value) => setBatchCode(value) }, status: {}, expiry_date: { @@ -120,7 +161,7 @@ export function useStockFields({ // TODO: refer to stock.py in original codebase return fields; - }, [part, supplierPart]); + }, [part, supplierPart, batchCode, serialNumbers, trackable, create]); } /** diff --git a/src/frontend/src/functions/icons.tsx b/src/frontend/src/functions/icons.tsx index 94ab038dd0..75002f4701 100644 --- a/src/frontend/src/functions/icons.tsx +++ b/src/frontend/src/functions/icons.tsx @@ -194,6 +194,7 @@ const icons = { downright: IconCornerDownRight, barcode: IconQrcode, barLine: IconMinusVertical, + batch: IconClipboardText, batch_code: IconClipboardText, destination: IconFlag, repeat_destination: IconFlagShare, diff --git a/src/frontend/src/hooks/UseGenerator.tsx b/src/frontend/src/hooks/UseGenerator.tsx new file mode 100644 index 0000000000..f2a0054d67 --- /dev/null +++ b/src/frontend/src/hooks/UseGenerator.tsx @@ -0,0 +1,90 @@ +import { useDebouncedValue } from '@mantine/hooks'; +import { useQuery } from '@tanstack/react-query'; +import { useCallback, useState } from 'react'; + +import { api } from '../App'; +import { ApiEndpoints } from '../enums/ApiEndpoints'; +import { apiUrl } from '../states/ApiState'; + +export type GeneratorState = { + query: Record; + result: any; + update: (params: Record, overwrite?: boolean) => void; +}; + +/* Hook for managing generation of data via the InvenTree API. + * We pass an endpoint, and start with an initially empty query. + * 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 { + // Track the result + const [result, setResult] = useState(null); + + // Track the generator query + const [query, setQuery] = useState>({}); + + // Prevent rapid updates + const [debouncedQuery] = useDebouncedValue>(query, 250); + + // Callback to update the generator query + const update = useCallback( + (params: Record, overwrite?: boolean) => { + if (overwrite) { + setQuery(params); + } else { + setQuery((query) => ({ + ...query, + ...params + })); + } + }, + [] + ); + + // API query handler + const queryGenerator = useQuery({ + enabled: true, + queryKey: ['generator', key, endpoint, debouncedQuery], + queryFn: async () => { + return api.post(apiUrl(endpoint), debouncedQuery).then((response) => { + const value = response?.data[key]; + setResult(value); + + if (onGenerate) { + onGenerate(value); + } + + return response; + }); + } + }); + + return { + query, + update, + result + }; +} + +// Generate a batch code with provided data +export function useBatchCodeGenerator(onGenerate: (value: any) => void) { + return useGenerator( + ApiEndpoints.generate_batch_code, + 'batch_code', + onGenerate + ); +} + +// Generate a serial number with provided data +export function useSerialNumberGenerator(onGenerate: (value: any) => void) { + return useGenerator( + ApiEndpoints.generate_serial_number, + 'serial_number', + onGenerate + ); +} diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index f78f7620aa..58c9871325 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -191,6 +191,13 @@ export default function BuildDetail() { model: ModelType.stocklocation, label: t`Destination Location`, hidden: !build.destination + }, + { + type: 'text', + name: 'batch', + label: t`Batch Code`, + hidden: !build.batch, + copy: true } ]; diff --git a/src/frontend/src/tables/build/BuildOutputTable.tsx b/src/frontend/src/tables/build/BuildOutputTable.tsx index 1ea8403896..89898fbcf8 100644 --- a/src/frontend/src/tables/build/BuildOutputTable.tsx +++ b/src/frontend/src/tables/build/BuildOutputTable.tsx @@ -113,6 +113,9 @@ export default function BuildOutputTable({ build }: { build: any }) { url: apiUrl(ApiEndpoints.build_output_create, buildId), title: t`Add Build Output`, fields: buildOutputFields, + initialData: { + batch_code: build.batch + }, table: table });