mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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 <code@mjmair.com> Co-authored-by: Lukas <76838159+wolflu05@users.noreply.github.com>
This commit is contained in:
parent
5cb61d5ad0
commit
e93d9c4a74
@ -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 `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
|
### Serial Numbers
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ from pathlib import Path
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
|
from django.urls import include, path
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from django_q.models import OrmQ
|
from django_q.models import OrmQ
|
||||||
|
@ -1,11 +1,16 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
v199 - 2024-05-20 : https://github.com/inventree/InvenTree/pull/7264
|
||||||
- Expose "bom_valid" filter for the Part API
|
- Expose "bom_valid" filter for the Part API
|
||||||
- Expose "starred" filter for the Part API
|
- Expose "starred" filter for the Part API
|
||||||
|
@ -293,13 +293,13 @@ def increment(value):
|
|||||||
QQQ -> QQQ
|
QQQ -> QQQ
|
||||||
|
|
||||||
"""
|
"""
|
||||||
value = str(value).strip()
|
|
||||||
|
|
||||||
# Ignore empty strings
|
# Ignore empty strings
|
||||||
if value in ['', None]:
|
if value in ['', None]:
|
||||||
# Provide a default value if provided with a null input
|
# Provide a default value if provided with a null input
|
||||||
return '1'
|
return '1'
|
||||||
|
|
||||||
|
value = str(value).strip()
|
||||||
|
|
||||||
pattern = r'(.*?)(\d+)?$'
|
pattern = r'(.*?)(\d+)?$'
|
||||||
|
|
||||||
result = re.search(pattern, value)
|
result = re.search(pattern, value)
|
||||||
|
@ -86,6 +86,21 @@ apipatterns = [
|
|||||||
path('part/', include(part.api.part_api_urls)),
|
path('part/', include(part.api.part_api_urls)),
|
||||||
path('bom/', include(part.api.bom_api_urls)),
|
path('bom/', include(part.api.bom_api_urls)),
|
||||||
path('company/', include(company.api.company_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('stock/', include(stock.api.stock_api_urls)),
|
||||||
path('build/', include(build.api.build_api_urls)),
|
path('build/', include(build.api.build_api_urls)),
|
||||||
path('order/', include(order.api.order_api_urls)),
|
path('order/', include(order.api.order_api_urls)),
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
"""JSON serializers for Build API."""
|
"""JSON serializers for Build API."""
|
||||||
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -22,7 +20,8 @@ import InvenTree.helpers
|
|||||||
from InvenTree.serializers import InvenTreeDecimalField
|
from InvenTree.serializers import InvenTreeDecimalField
|
||||||
from InvenTree.status_codes import StockStatus
|
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
|
from stock.serializers import StockItemSerializerBrief, LocationSerializer
|
||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
|
@ -129,9 +129,14 @@ class ValidationMixin:
|
|||||||
"""
|
"""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def generate_batch_code(self):
|
def generate_batch_code(self, **kwargs):
|
||||||
"""Generate a new batch code.
|
"""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:
|
Returns:
|
||||||
A new batch code (string) or None
|
A new batch code (string) or None
|
||||||
"""
|
"""
|
||||||
|
@ -145,7 +145,17 @@ class SampleValidatorPlugin(SettingsMixin, ValidationMixin, InvenTreePlugin):
|
|||||||
if len(batch_code) > 0 and prefix and not batch_code.startswith(prefix):
|
if len(batch_code) > 0 and prefix and not batch_code.startswith(prefix):
|
||||||
self.raise_error(f"Batch code must start with '{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."""
|
"""Generate a new batch code."""
|
||||||
now = datetime.now()
|
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
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
"""JSON API for the Stock app."""
|
"""JSON API for the Stock app."""
|
||||||
|
|
||||||
|
import json
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from datetime import timedelta
|
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 django_filters import rest_framework as rest_filters
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import extend_schema_field
|
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.response import Response
|
||||||
from rest_framework.serializers import ValidationError
|
from rest_framework.serializers import ValidationError
|
||||||
|
|
||||||
@ -64,6 +66,7 @@ from order.serializers import (
|
|||||||
from part.models import BomItem, Part, PartCategory
|
from part.models import BomItem, Part, PartCategory
|
||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer
|
||||||
from stock.admin import LocationResource, StockItemResource
|
from stock.admin import LocationResource, StockItemResource
|
||||||
|
from stock.generators import generate_batch_code, generate_serial_number
|
||||||
from stock.models import (
|
from stock.models import (
|
||||||
StockItem,
|
StockItem,
|
||||||
StockItemAttachment,
|
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):
|
class StockDetail(RetrieveUpdateDestroyAPI):
|
||||||
"""API detail endpoint for Stock object.
|
"""API detail endpoint for Stock object.
|
||||||
|
|
||||||
|
113
src/backend/InvenTree/stock/generators.py
Normal file
113
src/backend/InvenTree/stock/generators.py
Normal file
@ -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)
|
@ -1,6 +1,7 @@
|
|||||||
# Generated by Django 3.2.12 on 2022-04-26 10:19
|
# Generated by Django 3.2.12 on 2022-04-26 10:19
|
||||||
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
import stock.generators
|
||||||
import stock.models
|
import stock.models
|
||||||
|
|
||||||
|
|
||||||
@ -14,6 +15,6 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='stockitem',
|
model_name='stockitem',
|
||||||
name='batch',
|
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'),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@ -20,7 +20,6 @@ from django.dispatch import receiver
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from jinja2 import Template
|
|
||||||
from mptt.managers import TreeManager
|
from mptt.managers import TreeManager
|
||||||
from mptt.models import MPTTModel, TreeForeignKey
|
from mptt.models import MPTTModel, TreeForeignKey
|
||||||
from taggit.managers import TaggableManager
|
from taggit.managers import TaggableManager
|
||||||
@ -43,6 +42,7 @@ from InvenTree.status_codes import (
|
|||||||
)
|
)
|
||||||
from part import models as PartModels
|
from part import models as PartModels
|
||||||
from plugin.events import trigger_event
|
from plugin.events import trigger_event
|
||||||
|
from stock.generators import generate_batch_code
|
||||||
from users.models import Owner
|
from users.models import Owner
|
||||||
|
|
||||||
logger = logging.getLogger('inventree')
|
logger = logging.getLogger('inventree')
|
||||||
@ -295,47 +295,6 @@ class StockLocation(
|
|||||||
return self.get_stock_items(cascade=cascade)
|
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():
|
def default_delete_on_deplete():
|
||||||
"""Return a default value for the 'delete_on_deplete' field.
|
"""Return a default value for the 'delete_on_deplete' field.
|
||||||
|
|
||||||
|
@ -15,11 +15,13 @@ from rest_framework.serializers import ValidationError
|
|||||||
from sql_util.utils import SubqueryCount, SubquerySum
|
from sql_util.utils import SubqueryCount, SubquerySum
|
||||||
from taggit.serializers import TagListSerializerField
|
from taggit.serializers import TagListSerializerField
|
||||||
|
|
||||||
|
import build.models
|
||||||
import common.models
|
import common.models
|
||||||
import company.models
|
import company.models
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
import InvenTree.serializers
|
import InvenTree.serializers
|
||||||
import InvenTree.status_codes
|
import InvenTree.status_codes
|
||||||
|
import order.models
|
||||||
import part.filters as part_filters
|
import part.filters as part_filters
|
||||||
import part.models as part_models
|
import part.models as part_models
|
||||||
import stock.filters
|
import stock.filters
|
||||||
@ -39,6 +41,133 @@ from .models import (
|
|||||||
logger = logging.getLogger('inventree')
|
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):
|
class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||||
"""Provides a brief serializer for a StockLocation object."""
|
"""Provides a brief serializer for a StockLocation object."""
|
||||||
|
|
||||||
|
@ -108,6 +108,10 @@ export enum ApiEndpoints {
|
|||||||
stock_status = 'stock/status/',
|
stock_status = 'stock/status/',
|
||||||
stock_install = 'stock/:id/install',
|
stock_install = 'stock/:id/install',
|
||||||
|
|
||||||
|
// Generator API endpoints
|
||||||
|
generate_batch_code = 'generate/batch-code/',
|
||||||
|
generate_serial_number = 'generate/serial-number/',
|
||||||
|
|
||||||
// Order API endpoints
|
// Order API endpoints
|
||||||
purchase_order_list = 'order/po/',
|
purchase_order_list = 'order/po/',
|
||||||
purchase_order_line_list = 'order/po-line/',
|
purchase_order_line_list = 'order/po-line/',
|
||||||
|
@ -19,6 +19,7 @@ import { ApiEndpoints } from '../enums/ApiEndpoints';
|
|||||||
import { ModelType } from '../enums/ModelType';
|
import { ModelType } from '../enums/ModelType';
|
||||||
import { InvenTreeIcon } from '../functions/icons';
|
import { InvenTreeIcon } from '../functions/icons';
|
||||||
import { useCreateApiFormModal } from '../hooks/UseForm';
|
import { useCreateApiFormModal } from '../hooks/UseForm';
|
||||||
|
import { useBatchCodeGenerator } from '../hooks/UseGenerator';
|
||||||
import { apiUrl } from '../states/ApiState';
|
import { apiUrl } from '../states/ApiState';
|
||||||
import { PartColumn, StatusColumn } from '../tables/ColumnRenderers';
|
import { PartColumn, StatusColumn } from '../tables/ColumnRenderers';
|
||||||
|
|
||||||
@ -34,10 +35,19 @@ export function useBuildOrderFields({
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [batchCode, setBatchCode] = useState<string>('');
|
||||||
|
|
||||||
|
const batchGenerator = useBatchCodeGenerator((value: any) => {
|
||||||
|
if (!batchCode) {
|
||||||
|
setBatchCode(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
return {
|
return {
|
||||||
reference: {},
|
reference: {},
|
||||||
part: {
|
part: {
|
||||||
|
disabled: !create,
|
||||||
filters: {
|
filters: {
|
||||||
assembly: true,
|
assembly: true,
|
||||||
virtual: false
|
virtual: false
|
||||||
@ -49,6 +59,10 @@ export function useBuildOrderFields({
|
|||||||
record.default_location || record.category_default_location
|
record.default_location || record.category_default_location
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
batchGenerator.update({
|
||||||
|
part: value
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
title: {},
|
title: {},
|
||||||
@ -66,7 +80,10 @@ export function useBuildOrderFields({
|
|||||||
sales_order: {
|
sales_order: {
|
||||||
icon: <IconTruckDelivery />
|
icon: <IconTruckDelivery />
|
||||||
},
|
},
|
||||||
batch: {},
|
batch: {
|
||||||
|
value: batchCode,
|
||||||
|
onValueChange: (value: any) => setBatchCode(value)
|
||||||
|
},
|
||||||
target_date: {
|
target_date: {
|
||||||
icon: <IconCalendar />
|
icon: <IconCalendar />
|
||||||
},
|
},
|
||||||
@ -90,7 +107,7 @@ export function useBuildOrderFields({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [create, destination]);
|
}, [create, destination, batchCode]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBuildOrderOutputFields({
|
export function useBuildOrderOutputFields({
|
||||||
|
@ -39,6 +39,7 @@ import { ApiEndpoints } from '../enums/ApiEndpoints';
|
|||||||
import { ModelType } from '../enums/ModelType';
|
import { ModelType } from '../enums/ModelType';
|
||||||
import { InvenTreeIcon } from '../functions/icons';
|
import { InvenTreeIcon } from '../functions/icons';
|
||||||
import { useCreateApiFormModal } from '../hooks/UseForm';
|
import { useCreateApiFormModal } from '../hooks/UseForm';
|
||||||
|
import { useBatchCodeGenerator } from '../hooks/UseGenerator';
|
||||||
import { apiUrl } from '../states/ApiState';
|
import { apiUrl } from '../states/ApiState';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -212,6 +213,12 @@ function LineItemFormRow({
|
|||||||
input.changeFn(input.idx, 'location', location);
|
input.changeFn(input.idx, 'location', location);
|
||||||
}, [location]);
|
}, [location]);
|
||||||
|
|
||||||
|
const batchCodeGenerator = useBatchCodeGenerator((value: any) => {
|
||||||
|
if (!batchCode) {
|
||||||
|
setBatchCode(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// State for serializing
|
// State for serializing
|
||||||
const [batchCode, setBatchCode] = useState<string>('');
|
const [batchCode, setBatchCode] = useState<string>('');
|
||||||
const [serials, setSerials] = useState<string>('');
|
const [serials, setSerials] = useState<string>('');
|
||||||
@ -219,6 +226,13 @@ function LineItemFormRow({
|
|||||||
onClose: () => {
|
onClose: () => {
|
||||||
input.changeFn(input.idx, 'batch_code', '');
|
input.changeFn(input.idx, 'batch_code', '');
|
||||||
input.changeFn(input.idx, 'serial_numbers', '');
|
input.changeFn(input.idx, 'serial_numbers', '');
|
||||||
|
},
|
||||||
|
onOpen: () => {
|
||||||
|
// Generate a new batch code
|
||||||
|
batchCodeGenerator.update({
|
||||||
|
part: record?.supplier_part_detail?.part,
|
||||||
|
order: record?.order
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -21,6 +21,10 @@ import {
|
|||||||
useCreateApiFormModal,
|
useCreateApiFormModal,
|
||||||
useDeleteApiFormModal
|
useDeleteApiFormModal
|
||||||
} from '../hooks/UseForm';
|
} from '../hooks/UseForm';
|
||||||
|
import {
|
||||||
|
useBatchCodeGenerator,
|
||||||
|
useSerialNumberGenerator
|
||||||
|
} from '../hooks/UseGenerator';
|
||||||
import { apiUrl } from '../states/ApiState';
|
import { apiUrl } from '../states/ApiState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -34,15 +38,41 @@ export function useStockFields({
|
|||||||
const [part, setPart] = useState<number | null>(null);
|
const [part, setPart] = useState<number | null>(null);
|
||||||
const [supplierPart, setSupplierPart] = useState<number | null>(null);
|
const [supplierPart, setSupplierPart] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [batchCode, setBatchCode] = useState<string>('');
|
||||||
|
const [serialNumbers, setSerialNumbers] = useState<string>('');
|
||||||
|
|
||||||
|
const [trackable, setTrackable] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const batchGenerator = useBatchCodeGenerator((value: any) => {
|
||||||
|
if (!batchCode) {
|
||||||
|
setBatchCode(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const serialGenerator = useSerialNumberGenerator((value: any) => {
|
||||||
|
if (!serialNumbers && create && trackable) {
|
||||||
|
setSerialNumbers(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const fields: ApiFormFieldSet = {
|
const fields: ApiFormFieldSet = {
|
||||||
part: {
|
part: {
|
||||||
value: part,
|
value: part,
|
||||||
disabled: !create,
|
disabled: !create,
|
||||||
onValueChange: (change) => {
|
onValueChange: (value, record) => {
|
||||||
setPart(change);
|
setPart(value);
|
||||||
// TODO: implement remaining functionality from old stock.py
|
// 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
|
// Clear the 'supplier_part' field if the part is changed
|
||||||
setSupplierPart(null);
|
setSupplierPart(null);
|
||||||
}
|
}
|
||||||
@ -50,7 +80,9 @@ export function useStockFields({
|
|||||||
supplier_part: {
|
supplier_part: {
|
||||||
// TODO: icon
|
// TODO: icon
|
||||||
value: supplierPart,
|
value: supplierPart,
|
||||||
onValueChange: setSupplierPart,
|
onValueChange: (value) => {
|
||||||
|
setSupplierPart(value);
|
||||||
|
},
|
||||||
filters: {
|
filters: {
|
||||||
part_detail: true,
|
part_detail: true,
|
||||||
supplier_detail: true,
|
supplier_detail: true,
|
||||||
@ -70,22 +102,29 @@ export function useStockFields({
|
|||||||
},
|
},
|
||||||
location: {
|
location: {
|
||||||
hidden: !create,
|
hidden: !create,
|
||||||
|
onValueChange: (value) => {
|
||||||
|
batchGenerator.update({ location: value });
|
||||||
|
},
|
||||||
filters: {
|
filters: {
|
||||||
structural: false
|
structural: false
|
||||||
}
|
}
|
||||||
// TODO: icon
|
|
||||||
},
|
},
|
||||||
quantity: {
|
quantity: {
|
||||||
hidden: !create,
|
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: {
|
serial_numbers: {
|
||||||
// TODO: icon
|
|
||||||
field_type: 'string',
|
field_type: 'string',
|
||||||
label: t`Serial Numbers`,
|
label: t`Serial Numbers`,
|
||||||
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
|
disabled: !trackable,
|
||||||
|
hidden: !create,
|
||||||
|
value: serialNumbers,
|
||||||
|
onValueChange: (value) => setSerialNumbers(value)
|
||||||
},
|
},
|
||||||
serial: {
|
serial: {
|
||||||
hidden: create
|
hidden: create
|
||||||
@ -93,6 +132,8 @@ export function useStockFields({
|
|||||||
},
|
},
|
||||||
batch: {
|
batch: {
|
||||||
// TODO: icon
|
// TODO: icon
|
||||||
|
value: batchCode,
|
||||||
|
onValueChange: (value) => setBatchCode(value)
|
||||||
},
|
},
|
||||||
status: {},
|
status: {},
|
||||||
expiry_date: {
|
expiry_date: {
|
||||||
@ -120,7 +161,7 @@ export function useStockFields({
|
|||||||
// TODO: refer to stock.py in original codebase
|
// TODO: refer to stock.py in original codebase
|
||||||
|
|
||||||
return fields;
|
return fields;
|
||||||
}, [part, supplierPart]);
|
}, [part, supplierPart, batchCode, serialNumbers, trackable, create]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -194,6 +194,7 @@ const icons = {
|
|||||||
downright: IconCornerDownRight,
|
downright: IconCornerDownRight,
|
||||||
barcode: IconQrcode,
|
barcode: IconQrcode,
|
||||||
barLine: IconMinusVertical,
|
barLine: IconMinusVertical,
|
||||||
|
batch: IconClipboardText,
|
||||||
batch_code: IconClipboardText,
|
batch_code: IconClipboardText,
|
||||||
destination: IconFlag,
|
destination: IconFlag,
|
||||||
repeat_destination: IconFlagShare,
|
repeat_destination: IconFlagShare,
|
||||||
|
90
src/frontend/src/hooks/UseGenerator.tsx
Normal file
90
src/frontend/src/hooks/UseGenerator.tsx
Normal file
@ -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<string, any>;
|
||||||
|
result: any;
|
||||||
|
update: (params: Record<string, any>, 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<any>(null);
|
||||||
|
|
||||||
|
// Track the generator query
|
||||||
|
const [query, setQuery] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
// Prevent rapid updates
|
||||||
|
const [debouncedQuery] = useDebouncedValue<Record<string, any>>(query, 250);
|
||||||
|
|
||||||
|
// Callback to update the generator query
|
||||||
|
const update = useCallback(
|
||||||
|
(params: Record<string, any>, 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
|
||||||
|
);
|
||||||
|
}
|
@ -191,6 +191,13 @@ export default function BuildDetail() {
|
|||||||
model: ModelType.stocklocation,
|
model: ModelType.stocklocation,
|
||||||
label: t`Destination Location`,
|
label: t`Destination Location`,
|
||||||
hidden: !build.destination
|
hidden: !build.destination
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'batch',
|
||||||
|
label: t`Batch Code`,
|
||||||
|
hidden: !build.batch,
|
||||||
|
copy: true
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -113,6 +113,9 @@ export default function BuildOutputTable({ build }: { build: any }) {
|
|||||||
url: apiUrl(ApiEndpoints.build_output_create, buildId),
|
url: apiUrl(ApiEndpoints.build_output_create, buildId),
|
||||||
title: t`Add Build Output`,
|
title: t`Add Build Output`,
|
||||||
fields: buildOutputFields,
|
fields: buildOutputFields,
|
||||||
|
initialData: {
|
||||||
|
batch_code: build.batch
|
||||||
|
},
|
||||||
table: table
|
table: table
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user