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:
Oliver 2024-05-20 23:56:45 +10:00 committed by GitHub
parent 5cb61d5ad0
commit e93d9c4a74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 513 additions and 64 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)),

View File

@ -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

View File

@ -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
"""

View File

@ -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

View File

@ -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.

View 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)

View File

@ -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'),
),
]

View File

@ -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.

View File

@ -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."""

View File

@ -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/',

View File

@ -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<string>('');
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: <IconTruckDelivery />
},
batch: {},
batch: {
value: batchCode,
onValueChange: (value: any) => setBatchCode(value)
},
target_date: {
icon: <IconCalendar />
},
@ -90,7 +107,7 @@ export function useBuildOrderFields({
}
}
};
}, [create, destination]);
}, [create, destination, batchCode]);
}
export function useBuildOrderOutputFields({

View File

@ -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<string>('');
const [serials, setSerials] = useState<string>('');
@ -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
});
}
});

View File

@ -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<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(() => {
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]);
}
/**

View File

@ -194,6 +194,7 @@ const icons = {
downright: IconCornerDownRight,
barcode: IconQrcode,
barLine: IconMinusVertical,
batch: IconClipboardText,
batch_code: IconClipboardText,
destination: IconFlag,
repeat_destination: IconFlagShare,

View 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
);
}

View File

@ -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
}
];

View File

@ -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
});