2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-24 01:47:39 +00:00
Files
InvenTree/src/backend/InvenTree/stock/serializers.py
2025-10-15 00:05:25 +02:00

1832 lines
53 KiB
Python

"""JSON serializers for Stock app."""
from datetime import timedelta
from decimal import Decimal
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.db.models import BooleanField, Case, Count, Prefetch, Q, Value, When
from django.db.models.functions import Coalesce
from django.utils.translation import gettext_lazy as _
import structlog
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from rest_framework.serializers import ValidationError
from sql_util.utils import SubqueryCount, SubquerySum
from taggit.serializers import TagListSerializerField
import build.models
import company.models
import company.serializers as company_serializers
import InvenTree.helpers
import InvenTree.ready
import InvenTree.serializers
import order.models
import part.filters as part_filters
import part.models as part_models
import part.serializers as part_serializers
import stock.filters
import stock.status_codes
from common.settings import get_global_setting
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
from importer.registry import register_importer
from InvenTree.mixins import DataImportExportSerializerMixin
from InvenTree.serializers import (
FilterableListField,
InvenTreeCurrencySerializer,
InvenTreeDecimalField,
enable_filter,
)
from users.serializers import UserSerializer
from .models import (
StockItem,
StockItemTestResult,
StockItemTracking,
StockLocation,
StockLocationType,
)
logger = structlog.get_logger('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_number" field will return a string with multiple serial numbers
separated by a comma.
"""
class Meta:
"""Metaclass options."""
fields = ['serial_number', 'part', 'quantity']
read_only_fields = ['serial_number']
write_only_fields = ['part', 'quantity']
serial_number = serializers.CharField(
read_only=True,
allow_null=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."""
class Meta:
"""Metaclass options."""
model = StockLocation
fields = ['pk', 'name', 'pathstring']
@register_importer()
class StockItemTestResultSerializer(
InvenTree.serializers.FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTree.serializers.InvenTreeModelSerializer,
):
"""Serializer for the StockItemTestResult model."""
class Meta:
"""Metaclass options."""
model = StockItemTestResult
fields = [
'pk',
'stock_item',
'result',
'value',
'attachment',
'notes',
'test_station',
'started_datetime',
'finished_datetime',
'user',
'user_detail',
'date',
'template',
'template_detail',
]
read_only_fields = ['pk', 'user', 'date']
user_detail = enable_filter(
UserSerializer(source='user', read_only=True, allow_null=True)
)
template = serializers.PrimaryKeyRelatedField(
queryset=part_models.PartTestTemplate.objects.all(),
many=False,
required=False,
allow_null=True,
help_text=_('Template'),
label=_('Test template for this result'),
)
template_detail = enable_filter(
part_serializers.PartTestTemplateSerializer(
source='template', read_only=True, allow_null=True
)
)
attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(
required=False,
allow_null=True,
label=_('Attachment'),
help_text=_('Test result attachment'),
)
def validate(self, data):
"""Validate the test result data."""
stock_item = data['stock_item']
template = data.get('template', None)
test_name = None
if not template:
# To support legacy API, we can accept a test name instead of a template
# In such a case, we use the test name to lookup the appropriate template
request_data = self.context['request'].data
if type(request_data) is list and len(request_data) > 0:
request_data = request_data[0]
test_name = request_data.get('test', test_name)
test_key = InvenTree.helpers.generateTestKey(test_name)
ancestors = stock_item.part.get_ancestors(include_self=True)
# Find a template based on name
if template := part_models.PartTestTemplate.objects.filter(
part__tree_id=stock_item.part.tree_id, part__in=ancestors, key=test_key
).first():
data['template'] = template
else:
raise ValidationError({
'test': _('No matching test found for this part')
})
if not template:
raise ValidationError(_('Template ID or test name must be provided'))
data = super().validate(data)
started = data.get('started_datetime')
finished = data.get('finished_datetime')
if started is not None and finished is not None and started > finished:
raise ValidationError({
'finished_datetime': _(
'The test finished time cannot be earlier than the test started time'
)
})
return data
@register_importer()
class StockItemSerializer(
InvenTree.serializers.FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTreeCustomStatusSerializerMixin,
InvenTree.serializers.InvenTreeTagModelSerializer,
):
"""Serializer for a StockItem.
- Includes serialization for the linked part
- Includes serialization for the item location
"""
export_exclude_fields = ['tags', 'tracking_items']
export_child_fields = [
'part_detail.name',
'part_detail.description',
'part_detail.IPN',
'part_detail.revision',
'part_detail.pricing_min',
'part_detail.pricing_max',
'location_detail.name',
'location_detail.pathstring',
'supplier_part_detail.SKU',
'supplier_part_detail.MPN',
]
import_exclude_fields = ['location_path', 'serial_numbers', 'use_pack_size']
class Meta:
"""Metaclass options."""
model = StockItem
fields = [
'pk',
'part',
'quantity',
'serial',
'batch',
'location',
'belongs_to',
'build',
'consumed_by',
'customer',
'delete_on_deplete',
'expiry_date',
'in_stock',
'is_building',
'link',
'notes',
'owner',
'packaging',
'parent',
'purchase_order',
'purchase_order_reference',
'sales_order',
'sales_order_reference',
'status',
'status_text',
'status_custom_key',
'supplier_part',
'SKU',
'MPN',
'barcode_hash',
'updated',
'stocktake_date',
'purchase_price',
'purchase_price_currency',
'use_pack_size',
'serial_numbers',
'tests',
# Annotated fields
'allocated',
'expired',
'installed_items',
'child_items',
'location_path',
'stale',
'tracking_items',
'tags',
# Detail fields (FK relationships)
'supplier_part_detail',
'part_detail',
'location_detail',
]
read_only_fields = [
'allocated',
'barcode_hash',
'stocktake_date',
'stocktake_user',
'updated',
]
"""
These fields are read-only in this context.
They can be updated by accessing the appropriate API endpoints
"""
extra_kwargs = {
'use_pack_size': {'write_only': True},
'serial_numbers': {'write_only': True},
}
"""
Fields used when creating a stock item
"""
part = serializers.PrimaryKeyRelatedField(
queryset=part_models.Part.objects.all(),
many=False,
allow_null=False,
help_text=_('Base Part'),
label=_('Part'),
)
parent = serializers.PrimaryKeyRelatedField(
many=False,
read_only=True,
label=_('Parent Item'),
help_text=_('Parent stock item'),
)
location_path = enable_filter(
FilterableListField(
child=serializers.DictField(),
source='location.get_path',
read_only=True,
allow_null=True,
),
filter_name='path_detail',
)
in_stock = serializers.BooleanField(read_only=True, label=_('In Stock'))
"""
Field used when creating a stock item
"""
use_pack_size = serializers.BooleanField(
write_only=True,
required=False,
allow_null=True,
help_text=_(
'Use pack size when adding: the quantity defined is the number of packs'
),
label=_('Use pack size'),
)
serial_numbers = serializers.CharField(
write_only=True,
required=False,
allow_null=True,
help_text=_('Enter serial numbers for new items'),
)
def validate_part(self, part):
"""Ensure the provided Part instance is valid."""
if part.virtual:
raise ValidationError(_('Stock item cannot be created for virtual parts'))
return part
def update(self, instance, validated_data):
"""Custom update method to pass the user information through to the instance."""
instance._user = self.context.get('user', None)
status_custom_key = validated_data.pop('status_custom_key', None)
status = validated_data.pop('status', None)
instance = super().update(instance, validated_data=validated_data)
if status_code := status_custom_key or status:
if not instance.compare_status(status_code):
instance.set_status(status_code)
instance.save()
return instance
@staticmethod
def annotate_queryset(queryset):
"""Add some extra annotations to the queryset, performing database queries as efficiently as possible."""
queryset = queryset.prefetch_related(
'location',
'allocations',
'sales_order',
'sales_order_allocations',
'purchase_order',
Prefetch(
'part',
queryset=part_models.Part.objects.annotate(
category_default_location=part_filters.annotate_default_location(
'category__'
)
).prefetch_related(None),
),
'parent',
'part__category',
'part__supplier_parts',
'part__supplier_parts__purchase_order_line_items',
'part__pricing_data',
'part__tags',
'supplier_part',
'supplier_part__part',
'supplier_part__supplier',
'supplier_part__manufacturer_part',
'supplier_part__manufacturer_part__manufacturer',
'supplier_part__manufacturer_part__tags',
'supplier_part__purchase_order_line_items',
'supplier_part__tags',
'test_results',
'customer',
'belongs_to',
'sales_order',
'consumed_by',
'tags',
)
# Annotate the queryset with the total allocated to sales orders
queryset = queryset.annotate(
allocated=Coalesce(
SubquerySum('sales_order_allocations__quantity'), Decimal(0)
)
+ Coalesce(SubquerySum('allocations__quantity'), Decimal(0))
)
# Annotate the queryset with the number of tracking items
queryset = queryset.annotate(tracking_items=SubqueryCount('tracking_info'))
# Add flag to indicate if the StockItem has expired
queryset = queryset.annotate(
expired=Case(
When(
StockItem.EXPIRED_FILTER,
then=Value(True, output_field=BooleanField()),
),
default=Value(False, output_field=BooleanField()),
)
)
# Add flag to indicate if the StockItem is stale
stale_days = get_global_setting('STOCK_STALE_DAYS')
stale_date = InvenTree.helpers.current_date() + timedelta(days=stale_days)
stale_filter = (
StockItem.IN_STOCK_FILTER
& ~Q(expiry_date=None)
& Q(expiry_date__lt=stale_date)
)
queryset = queryset.annotate(
stale=Case(
When(stale_filter, then=Value(True, output_field=BooleanField())),
default=Value(False, output_field=BooleanField()),
)
)
# Annotate with the total number of "installed items"
queryset = queryset.annotate(installed_items=SubqueryCount('installed_parts'))
# Annotate with the total number of "child items" (split stock items)
queryset = queryset.annotate(child_items=SubqueryCount('children'))
return queryset
status_text = serializers.CharField(
source='get_status_display', read_only=True, label=_('Status')
)
SKU = serializers.CharField(
source='supplier_part.SKU',
read_only=True,
label=_('Supplier Part Number'),
allow_null=True,
)
MPN = serializers.CharField(
source='supplier_part.manufacturer_part.MPN',
read_only=True,
label=_('Manufacturer Part Number'),
allow_null=True,
)
# Optional detail fields, which can be appended via query parameters
supplier_part_detail = enable_filter(
company_serializers.SupplierPartSerializer(
label=_('Supplier Part'),
source='supplier_part',
brief=True,
supplier_detail=False,
manufacturer_detail=False,
part_detail=False,
many=False,
read_only=True,
allow_null=True,
),
True,
)
part_detail = enable_filter(
part_serializers.PartBriefSerializer(
label=_('Part'), source='part', many=False, read_only=True, allow_null=True
),
True,
)
location_detail = enable_filter(
LocationBriefSerializer(
label=_('Location'),
source='location',
many=False,
read_only=True,
allow_null=True,
),
True,
)
tests = enable_filter(
StockItemTestResultSerializer(
source='test_results', many=True, read_only=True, allow_null=True
)
)
quantity = InvenTreeDecimalField()
# Annotated fields
allocated = serializers.FloatField(
read_only=True, allow_null=True, label=_('Allocated Quantity')
)
expired = serializers.BooleanField(
read_only=True, allow_null=True, label=_('Expired')
)
installed_items = serializers.IntegerField(
read_only=True, allow_null=True, label=_('Installed Items')
)
child_items = serializers.IntegerField(
read_only=True, allow_null=True, label=_('Child Items')
)
stale = serializers.BooleanField(read_only=True, allow_null=True, label=_('Stale'))
tracking_items = serializers.IntegerField(
read_only=True, allow_null=True, label=_('Tracking Items')
)
purchase_price = InvenTree.serializers.InvenTreeMoneySerializer(
label=_('Purchase Price'),
allow_null=True,
help_text=_('Purchase price of this stock item, per unit or pack'),
)
purchase_price_currency = InvenTreeCurrencySerializer(
help_text=_('Purchase currency of this stock item')
)
purchase_order_reference = serializers.CharField(
source='purchase_order.reference', read_only=True, allow_null=True
)
sales_order_reference = serializers.CharField(
source='sales_order.reference', read_only=True, allow_null=True
)
tags = TagListSerializerField(required=False)
class SerializeStockItemSerializer(serializers.Serializer):
"""A DRF serializer for "serializing" a StockItem.
(Sorry for the confusing naming...)
Here, "serializing" means splitting out a single StockItem,
into multiple single-quantity items with an assigned serial number
Note: The base StockItem object is provided to the serializer context
"""
class Meta:
"""Metaclass options."""
fields = ['quantity', 'serial_numbers', 'destination', 'notes']
quantity = serializers.IntegerField(
min_value=0,
required=True,
label=_('Quantity'),
help_text=_('Enter number of stock items to serialize'),
)
def validate_quantity(self, quantity):
"""Validate that the quantity value is correct."""
item = self.context.get('item')
if not item:
raise ValidationError(_('No stock item provided'))
if quantity < 0:
raise ValidationError(_('Quantity must be greater than zero'))
if quantity > item.quantity:
q = item.quantity
raise ValidationError(
_(f'Quantity must not exceed available stock quantity ({q})')
)
return quantity
serial_numbers = serializers.CharField(
label=_('Serial Numbers'),
help_text=_('Enter serial numbers for new items'),
allow_blank=False,
required=True,
)
destination = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(),
many=False,
required=True,
allow_null=False,
label=_('Location'),
help_text=_('Destination stock location'),
)
notes = serializers.CharField(
required=False,
allow_blank=True,
label=_('Notes'),
help_text=_('Optional note field'),
)
def validate(self, data):
"""Check that the supplied serial numbers are valid."""
data = super().validate(data)
item = self.context.get('item')
if not item:
raise ValidationError(_('No stock item provided'))
if not item.part.trackable:
raise ValidationError(_('Serial numbers cannot be assigned to this part'))
# Ensure the serial numbers are valid!
quantity = data['quantity']
serial_numbers = data['serial_numbers']
try:
serials = InvenTree.helpers.extract_serial_numbers(
serial_numbers,
quantity,
item.part.get_latest_serial_number(),
part=item.part,
)
except DjangoValidationError as e:
raise ValidationError({'serial_numbers': e.messages})
existing = item.part.find_conflicting_serial_numbers(serials)
if len(existing) > 0:
exists = ','.join([str(x) for x in existing])
error = _('Serial numbers already exist') + ': ' + exists
raise ValidationError({'serial_numbers': error})
return data
def save(self) -> list[StockItem]:
"""Serialize the provided StockItem.
Returns:
A list of StockItem objects that were created as a result of the serialization.
"""
item = self.context.get('item')
if not item:
raise ValidationError(_('No stock item provided'))
request = self.context.get('request')
user = request.user if request else None
data = self.validated_data
serials = InvenTree.helpers.extract_serial_numbers(
data['serial_numbers'],
data['quantity'],
item.part.get_latest_serial_number(),
part=item.part,
)
return (
item.serializeStock(
data['quantity'],
serials,
user=user,
notes=data.get('notes', ''),
location=data['destination'],
)
or []
)
class InstallStockItemSerializer(serializers.Serializer):
"""Serializer for installing a stock item into a given part."""
stock_item = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(),
many=False,
required=True,
allow_null=False,
label=_('Stock Item'),
help_text=_('Select stock item to install'),
)
quantity = serializers.IntegerField(
min_value=1,
default=1,
required=False,
label=_('Quantity to Install'),
help_text=_('Enter the quantity of items to install'),
)
note = serializers.CharField(
label=_('Note'),
help_text=_('Add transaction note (optional)'),
required=False,
allow_blank=True,
)
def validate_quantity(self, quantity):
"""Validate the quantity value."""
if quantity < 1:
raise ValidationError(_('Quantity to install must be at least 1'))
return quantity
def validate_stock_item(self, stock_item):
"""Validate the selected stock item."""
if not stock_item.in_stock:
# StockItem must be in stock to be "installed"
raise ValidationError(_('Stock item is unavailable'))
parent_item = self.context['item']
parent_part = parent_item.part
if get_global_setting(
'STOCK_ENFORCE_BOM_INSTALLATION', backup_value=True, cache=False
):
# Check if the selected part is in the Bill of Materials of the parent item
if not parent_part.check_if_part_in_bom(stock_item.part):
raise ValidationError(
_('Selected part is not in the Bill of Materials')
)
return stock_item
def validate(self, data):
"""Ensure that the provided dataset is valid."""
stock_item = data['stock_item']
quantity = data.get('quantity', stock_item.quantity)
if quantity > stock_item.quantity:
raise ValidationError({
'quantity': _('Quantity to install must not exceed available quantity')
})
return data
def save(self):
"""Install the selected stock item into this one."""
data = self.validated_data
stock_item = data['stock_item']
quantity_to_install = data.get('quantity', stock_item.quantity)
note = data.get('note', '')
parent_item = self.context['item']
request = self.context['request']
parent_item.installStockItem(
stock_item, quantity_to_install, request.user, note
)
class UninstallStockItemSerializer(serializers.Serializer):
"""API serializers for uninstalling an installed item from a stock item."""
class Meta:
"""Metaclass options."""
fields = ['location', 'note']
location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(),
many=False,
required=True,
allow_null=False,
label=_('Location'),
help_text=_('Destination location for uninstalled item'),
)
note = serializers.CharField(
label=_('Notes'),
help_text=_('Add transaction note (optional)'),
required=False,
allow_blank=True,
)
def save(self):
"""Uninstall stock item."""
item = self.context.get('item')
if not item:
raise ValidationError(_('No stock item provided'))
data = self.validated_data
request = self.context['request']
location = data['location']
note = data.get('note', '')
item.uninstall_into_location(location, request.user, note)
class ConvertStockItemSerializer(serializers.Serializer):
"""DRF serializer class for converting a StockItem to a valid variant part."""
class Meta:
"""Metaclass options."""
fields = ['part']
part = serializers.PrimaryKeyRelatedField(
queryset=part_models.Part.objects.all(),
label=_('Part'),
help_text=_('Select part to convert stock item into'),
many=False,
required=True,
allow_null=False,
)
def validate_part(self, part):
"""Ensure that the provided part is a valid option for the stock item."""
stock_item = self.context['item']
valid_options = stock_item.part.get_conversion_options()
if part not in valid_options:
raise ValidationError(
_('Selected part is not a valid option for conversion')
)
return part
def validate(self, data):
"""Ensure that the stock item is valid for conversion.
Rules:
- If a SupplierPart is assigned, we cannot convert!
"""
data = super().validate(data)
stock_item = self.context['item']
if stock_item.supplier_part is not None:
raise ValidationError(
_('Cannot convert stock item with assigned SupplierPart')
)
return data
def save(self):
"""Save the serializer to convert the StockItem to the selected Part."""
data = self.validated_data
part = data['part']
stock_item = self.context['item']
request = self.context['request']
stock_item.convert_to_variant(part, request.user)
@extend_schema_field(
serializers.IntegerField(
help_text='Status key, chosen from the list of StockStatus keys'
)
)
class StockStatusCustomSerializer(serializers.ChoiceField):
"""Serializer to allow annotating the schema to use int where custom values may be entered."""
def __init__(self, *args, **kwargs):
"""Initialize the status selector."""
if 'choices' not in kwargs:
kwargs['choices'] = stock.status_codes.StockStatus.items(custom=True)
if 'label' not in kwargs:
kwargs['label'] = _('Status')
if 'help_text' not in kwargs:
kwargs['help_text'] = _('Stock item status code')
if InvenTree.ready.isGeneratingSchema():
kwargs['help_text'] = (
kwargs['help_text']
+ '\n\n'
+ '\n'.join(
f'* `{value}` - {label}' for value, label in kwargs['choices']
)
+ "\n\nAdditional custom status keys may be retrieved from the 'stock_status_retrieve' call."
)
super().__init__(*args, **kwargs)
class StockChangeStatusSerializer(serializers.Serializer):
"""Serializer for changing status of multiple StockItem objects."""
class Meta:
"""Metaclass options."""
fields = ['items', 'status', 'note']
items = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(),
many=True,
required=True,
allow_null=False,
label=_('Stock Items'),
help_text=_('Select stock items to change status'),
)
def validate_items(self, items):
"""Validate the selected stock items."""
if len(items) == 0:
raise ValidationError(_('No stock items selected'))
return items
status = StockStatusCustomSerializer(
default=stock.status_codes.StockStatus.OK.value
)
note = serializers.CharField(
label=_('Notes'),
help_text=_('Add transaction note (optional)'),
required=False,
allow_blank=True,
)
@transaction.atomic
def save(self):
"""Save the serializer to change the status of the selected stock items."""
data = self.validated_data
items = data['items']
status = data['status']
request = self.context['request']
user = getattr(request, 'user', None)
note = data.get('note', '')
transaction_notes = []
deltas = {'status': status}
now = InvenTree.helpers.current_time()
# Instead of performing database updates for each item,
# perform bulk database updates (much more efficient)
for item in items:
# Ignore items which are already in the desired status
if item.compare_status(status):
continue
item.set_status(status)
item.save(add_note=False)
# Create a new transaction note for each item
transaction_notes.append(
StockItemTracking(
item=item,
tracking_type=stock.status_codes.StockHistoryCode.EDITED.value,
date=now,
deltas=deltas,
user=user,
notes=note,
)
)
# Create tracking entries
StockItemTracking.objects.bulk_create(transaction_notes)
class StockLocationTypeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for StockLocationType model."""
class Meta:
"""Serializer metaclass."""
model = StockLocationType
fields = ['pk', 'name', 'description', 'icon', 'location_count']
read_only_fields = ['location_count']
location_count = serializers.IntegerField(read_only=True, allow_null=True)
@staticmethod
def annotate_queryset(queryset):
"""Add location count to each location type."""
return queryset.annotate(location_count=Count('stock_locations'))
class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for a simple tree view."""
class Meta:
"""Metaclass options."""
model = StockLocation
fields = ['pk', 'name', 'parent', 'icon', 'structural', 'sublocations']
sublocations = serializers.IntegerField(label=_('Sublocations'), read_only=True)
@staticmethod
def annotate_queryset(queryset):
"""Annotate the queryset with the number of sublocations."""
return queryset.annotate(sublocations=stock.filters.annotate_sub_locations())
@register_importer()
class LocationSerializer(
InvenTree.serializers.FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTree.serializers.InvenTreeTagModelSerializer,
):
"""Detailed information about a stock location."""
import_exclude_fields = []
class Meta:
"""Metaclass options."""
model = StockLocation
fields = [
'pk',
'barcode_hash',
'name',
'level',
'description',
'parent',
'pathstring',
'path',
'items',
'sublocations',
'owner',
'icon',
'custom_icon',
'structural',
'external',
'location_type',
'location_type_detail',
'tags',
]
read_only_fields = ['barcode_hash', 'icon', 'level', 'pathstring']
@staticmethod
def annotate_queryset(queryset):
"""Annotate extra information to the queryset."""
# Annotate the number of stock items which exist in this category (including subcategories)
queryset = queryset.prefetch_related('tags')
queryset = queryset.annotate(
items=stock.filters.annotate_location_items(),
sublocations=stock.filters.annotate_sub_locations(),
)
return queryset
parent = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(),
many=False,
allow_null=True,
required=False,
label=_('Parent Location'),
help_text=_('Parent stock location'),
)
items = serializers.IntegerField(read_only=True, label=_('Stock Items'))
sublocations = serializers.IntegerField(read_only=True, label=_('Sublocations'))
level = serializers.IntegerField(read_only=True)
tags = TagListSerializerField(required=False)
path = enable_filter(
FilterableListField(
child=serializers.DictField(),
source='get_path',
read_only=True,
allow_null=True,
),
filter_name='path_detail',
)
# explicitly set this field, so it gets included for AutoSchema
icon = serializers.CharField(read_only=True)
# Detail for location type
location_type_detail = StockLocationTypeSerializer(
source='location_type', read_only=True, allow_null=True, many=False
)
@register_importer()
class StockTrackingSerializer(
InvenTree.serializers.FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTree.serializers.InvenTreeModelSerializer,
):
"""Serializer for StockItemTracking model."""
class Meta:
"""Metaclass options."""
model = StockItemTracking
fields = [
'pk',
'item',
'item_detail',
'date',
'deltas',
'label',
'notes',
'tracking_type',
'user',
'user_detail',
]
read_only_fields = ['date', 'user', 'label', 'tracking_type']
label = serializers.CharField(read_only=True)
item_detail = enable_filter(
StockItemSerializer(source='item', many=False, read_only=True, allow_null=True)
)
user_detail = enable_filter(
UserSerializer(source='user', many=False, read_only=True, allow_null=True)
)
deltas = serializers.JSONField(read_only=True)
class StockAssignmentItemSerializer(serializers.Serializer):
"""Serializer for a single StockItem with in StockAssignment request.
Here, the particular StockItem is being assigned (manually) to a customer
Fields:
- item: StockItem object
"""
class Meta:
"""Metaclass options."""
fields = ['item']
item = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Stock Item'),
)
def validate_item(self, item):
"""Validate item.
Ensures:
- is in stock
- Is salable
- Is not allocated
"""
# The item must currently be "in stock"
if not item.in_stock:
raise ValidationError(_('Item must be in stock'))
# The base part must be "salable"
if not item.part.salable:
raise ValidationError(_('Part must be salable'))
# The item must not be allocated to a sales order
if item.sales_order_allocations.count() > 0:
raise ValidationError(_('Item is allocated to a sales order'))
# The item must not be allocated to a build order
if item.allocations.count() > 0:
raise ValidationError(_('Item is allocated to a build order'))
return item
class StockAssignmentSerializer(serializers.Serializer):
"""Serializer for assigning one (or more) stock items to a customer.
This is a manual assignment process, separate for (for example) a Sales Order
"""
class Meta:
"""Metaclass options."""
fields = ['items', 'customer', 'notes']
items = StockAssignmentItemSerializer(many=True, required=True)
customer = serializers.PrimaryKeyRelatedField(
queryset=company.models.Company.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Customer'),
help_text=_('Customer to assign stock items'),
)
def validate_customer(self, customer):
"""Make sure provided company is customer."""
if customer and not customer.is_customer:
raise ValidationError(_('Selected company is not a customer'))
return customer
notes = serializers.CharField(
required=False,
allow_blank=True,
label=_('Notes'),
help_text=_('Stock assignment notes'),
)
def validate(self, data):
"""Make sure items were provided."""
data = super().validate(data)
items = data.get('items', [])
if len(items) == 0:
raise ValidationError(_('A list of stock items must be provided'))
return data
def save(self):
"""Assign stock."""
request = self.context['request']
user = getattr(request, 'user', None)
data = self.validated_data
items = data['items']
customer = data['customer']
notes = data.get('notes', '')
with transaction.atomic():
for item in items:
stock_item = item['item']
stock_item.allocateToCustomer(customer, user=user, notes=notes)
class StockMergeItemSerializer(serializers.Serializer):
"""Serializer for a single StockItem within the StockMergeSerializer class.
Here, the individual StockItem is being checked for merge compatibility.
"""
class Meta:
"""Metaclass options."""
fields = ['item']
item = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Stock Item'),
)
def validate_item(self, item):
"""Make sure item can be merged."""
# Check that the stock item is able to be merged
item.can_merge(raise_error=True)
return item
class StockMergeSerializer(serializers.Serializer):
"""Serializer for merging two (or more) stock items together."""
class Meta:
"""Metaclass options."""
fields = [
'items',
'location',
'notes',
'allow_mismatched_suppliers',
'allow_mismatched_status',
]
items = StockMergeItemSerializer(many=True, required=True)
location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(),
many=False,
required=True,
allow_null=False,
label=_('Location'),
help_text=_('Destination stock location'),
)
notes = serializers.CharField(
required=False,
allow_blank=True,
label=_('Notes'),
help_text=_('Stock merging notes'),
)
allow_mismatched_suppliers = serializers.BooleanField(
required=False,
label=_('Allow mismatched suppliers'),
help_text=_('Allow stock items with different supplier parts to be merged'),
)
allow_mismatched_status = serializers.BooleanField(
required=False,
label=_('Allow mismatched status'),
help_text=_('Allow stock items with different status codes to be merged'),
)
def validate(self, data):
"""Make sure all needed values are provided and that the items can be merged."""
data = super().validate(data)
items = data['items']
if len(items) < 2:
raise ValidationError(_('At least two stock items must be provided'))
unique_items = set()
# The "base item" is the first item
base_item = items[0]['item']
data['base_item'] = base_item
# Ensure stock items are unique!
for element in items:
item = element['item']
if item.pk in unique_items:
raise ValidationError(_('Duplicate stock items'))
unique_items.add(item.pk)
# Checks from here refer to the "base_item"
if item == base_item:
continue
# Check that this item can be merged with the base_item
item.can_merge(
raise_error=True,
other=base_item,
allow_mismatched_suppliers=data.get(
'allow_mismatched_suppliers', False
),
allow_mismatched_status=data.get('allow_mismatched_status', False),
)
return data
def save(self):
"""Actually perform the stock merging action.
At this point we are confident that the merge can take place
"""
data = self.validated_data
base_item = data['base_item']
items = data['items'][1:]
request = self.context['request']
user = getattr(request, 'user', None)
items = []
for item in data['items'][1:]:
items.append(item['item'])
base_item.merge_stock_items(
items,
allow_mismatched_suppliers=data.get('allow_mismatched_suppliers', False),
allow_mismatched_status=data.get('allow_mismatched_status', False),
user=user,
location=data['location'],
notes=data.get('notes', None),
)
def stock_item_adjust_status_options():
"""Return a custom set of options for the StockItem status adjustment field.
In particular, include a Null option for the status field.
"""
return [(None, _('No Change')), *stock.status_codes.StockStatus.items(custom=True)]
class StockAdjustmentItemSerializer(serializers.Serializer):
"""Serializer for a single StockItem within a stock adjustment request.
Required Fields:
- item: StockItem object
- quantity: Numerical quantity
Optional Fields (may be used by external tools)
- status: Change StockItem status code
- packaging: Change StockItem packaging
- batch: Change StockItem batch code
The optional fields can be used to adjust values for individual stock items
"""
class Meta:
"""Metaclass options."""
fields = ['pk', 'quantity', 'batch', 'status', 'packaging']
def __init__(self, *args, **kwargs):
"""Initialize the serializer."""
# Store the 'require_in_stock' status
# Either True / False / None
self.require_in_stock = kwargs.pop('require_in_stock', True)
self.require_non_zero = kwargs.pop('require_non_zero', False)
super().__init__(*args, **kwargs)
pk = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(),
many=False,
allow_null=False,
required=True,
label='stock_item',
help_text=_('StockItem primary key value'),
)
def validate_pk(self, stock_item: StockItem) -> StockItem:
"""Ensure the stock item is valid."""
if self.require_in_stock == True:
allow_out_of_stock_transfer = get_global_setting(
'STOCK_ALLOW_OUT_OF_STOCK_TRANSFER', backup_value=False, cache=False
)
if not allow_out_of_stock_transfer and not stock_item.is_in_stock(
check_status=False, check_quantity=False
):
raise ValidationError(_('Stock item is not in stock'))
elif self.require_in_stock == False:
if stock_item.is_in_stock():
raise ValidationError(_('Stock item is already in stock'))
return stock_item
quantity = serializers.DecimalField(
max_digits=15, decimal_places=5, min_value=Decimal(0), required=True
)
def validate_quantity(self, quantity):
"""Validate the quantity value."""
if self.require_non_zero and quantity <= 0:
raise ValidationError(_('Quantity must be greater than zero'))
if quantity < 0:
raise ValidationError(_('Quantity must not be negative'))
return quantity
batch = serializers.CharField(
max_length=100,
required=False,
allow_blank=True,
label=_('Batch Code'),
help_text=_('Batch code for this stock item'),
)
status = StockStatusCustomSerializer(
choices=stock_item_adjust_status_options(),
default=None,
required=False,
allow_blank=True,
)
packaging = serializers.CharField(
max_length=50,
required=False,
allow_blank=True,
label=_('Packaging'),
help_text=_('Packaging this stock item is stored in'),
)
class StockAdjustmentSerializer(serializers.Serializer):
"""Base class for managing stock adjustment actions via the API."""
class Meta:
"""Metaclass options."""
fields = ['items', 'notes']
items = StockAdjustmentItemSerializer(many=True)
notes = serializers.CharField(
required=False,
allow_blank=True,
label=_('Notes'),
help_text=_('Stock transaction notes'),
)
def validate(self, data):
"""Make sure items are provided."""
super().validate(data)
items = data.get('items', [])
if len(items) == 0:
raise ValidationError(_('A list of stock items must be provided'))
return data
class StockCountSerializer(StockAdjustmentSerializer):
"""Serializer for counting stock items."""
def save(self):
"""Count stock."""
request = self.context['request']
data = self.validated_data
items = data['items']
notes = data.get('notes', '')
with transaction.atomic():
for item in items:
stock_item = item['pk']
quantity = item['quantity']
# Optional fields
extra = {}
for field_name in StockItem.optional_transfer_fields():
if field_value := item.get(field_name, None):
extra[field_name] = field_value
stock_item.stocktake(quantity, request.user, notes=notes, **extra)
class StockAddSerializer(StockAdjustmentSerializer):
"""Serializer for adding stock to stock item(s)."""
def save(self):
"""Add stock."""
request = self.context['request']
data = self.validated_data
notes = data.get('notes', '')
with transaction.atomic():
for item in data['items']:
stock_item = item['pk']
quantity = item['quantity']
# Optional fields
extra = {}
for field_name in StockItem.optional_transfer_fields():
if field_value := item.get(field_name, None):
extra[field_name] = field_value
stock_item.add_stock(quantity, request.user, notes=notes, **extra)
class StockRemoveSerializer(StockAdjustmentSerializer):
"""Serializer for removing stock from stock item(s)."""
def save(self):
"""Remove stock."""
request = self.context['request']
data = self.validated_data
notes = data.get('notes', '')
with transaction.atomic():
for item in data['items']:
stock_item = item['pk']
quantity = item['quantity']
# Optional fields
extra = {}
for field_name in StockItem.optional_transfer_fields():
if field_value := item.get(field_name, None):
extra[field_name] = field_value
stock_item.take_stock(quantity, request.user, notes=notes, **extra)
class StockTransferSerializer(StockAdjustmentSerializer):
"""Serializer for transferring (moving) stock item(s)."""
class Meta:
"""Metaclass options."""
fields = ['items', 'notes', 'location']
items = StockAdjustmentItemSerializer(many=True, require_non_zero=True)
location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.filter(structural=False),
many=False,
required=True,
allow_null=False,
label=_('Location'),
help_text=_('Destination stock location'),
)
def save(self):
"""Transfer stock."""
request = self.context['request']
data = self.validated_data
items = data['items']
notes = data.get('notes', '')
location = data['location']
with transaction.atomic():
for item in items:
# Required fields
stock_item = item['pk']
quantity = item['quantity']
# Optional fields
kwargs = {}
for field_name in StockItem.optional_transfer_fields():
if field_value := item.get(field_name, None):
kwargs[field_name] = field_value
stock_item.move(
location, notes, request.user, quantity=quantity, **kwargs
)
class StockReturnSerializer(StockAdjustmentSerializer):
"""Serializer class for returning stock item(s) into stock."""
class Meta:
"""Metaclass options."""
fields = ['items', 'notes', 'location']
items = StockAdjustmentItemSerializer(
many=True, require_in_stock=False, require_non_zero=True
)
location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.filter(structural=False),
many=False,
required=True,
allow_null=False,
label=_('Location'),
help_text=_('Destination stock location'),
)
merge = serializers.BooleanField(
default=False,
required=False,
label=_('Merge into existing stock'),
help_text=_('Merge returned items into existing stock items if possible'),
)
def save(self):
"""Return the provided items into stock."""
request = self.context['request']
data = self.validated_data
items = data['items']
merge = data.get('merge', False)
notes = data.get('notes', '')
location = data['location']
with transaction.atomic():
for item in items:
stock_item = item['pk']
quantity = item.get('quantity', None)
# Optional fields
kwargs = {'notes': notes}
for field_name in StockItem.optional_transfer_fields():
if field_value := item.get(field_name, None):
kwargs[field_name] = field_value
stock_item.return_to_stock(
location,
quantity=quantity,
merge=merge,
user=request.user,
**kwargs,
)
class StockItemSerialNumbersSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for extra serial number information about a stock item."""
class Meta:
"""Metaclass options."""
model = StockItem
fields = ['next', 'previous']
next = StockItemSerializer(
read_only=True, source='get_next_stock_item', label=_('Next Serial Number')
)
previous = StockItemSerializer(
read_only=True,
source='get_previous_stock_item',
label=_('Previous Serial Number'),
)