"""JSON serializers for Stock app.""" from datetime import datetime, 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, Q, Value, When from django.db.models.functions import Coalesce from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from rest_framework.serializers import ValidationError from sql_util.utils import SubqueryCount, SubquerySum import common.models import company.models import InvenTree.helpers import InvenTree.serializers import part.models as part_models import stock.filters from common.settings import currency_code_default, currency_code_mappings from company.serializers import SupplierPartSerializer from InvenTree.models import extract_int from InvenTree.serializers import InvenTreeDecimalField from part.serializers import PartBriefSerializer from .models import (StockItem, StockItemAttachment, StockItemTestResult, StockItemTracking, StockLocation) class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Provides a brief serializer for a StockLocation object.""" class Meta: """Metaclass options.""" model = StockLocation fields = [ 'pk', 'name', 'pathstring', ] class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer): """Brief serializers for a StockItem.""" part_name = serializers.CharField(source='part.full_name', read_only=True) quantity = InvenTreeDecimalField() class Meta: """Metaclass options.""" model = StockItem fields = [ 'part', 'part_name', 'pk', 'location', 'quantity', 'serial', 'supplier_part', 'uid', ] def validate_serial(self, value): """Make sure serial is not to big.""" if abs(extract_int(value)) > 0x7fffffff: raise serializers.ValidationError(_("Serial number is too large")) return value class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for a StockItem. - Includes serialization for the linked part - Includes serialization for the item location """ part = serializers.PrimaryKeyRelatedField( queryset=part_models.Part.objects.all(), many=False, allow_null=False, help_text=_("Base Part"), label=_("Part"), ) 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['user'] return super().update(instance, validated_data) @staticmethod def annotate_queryset(queryset): """Add some extra annotations to the queryset, performing database queries as efficiently as possible.""" queryset = queryset.prefetch_related( 'sales_order', 'purchase_order', ) # 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 = common.models.InvenTreeSetting.get_setting('STOCK_STALE_DAYS') stale_date = datetime.now().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()), ) ) return queryset status_text = serializers.CharField(source='get_status_display', read_only=True) supplier_part_detail = SupplierPartSerializer(source='supplier_part', many=False, read_only=True) part_detail = PartBriefSerializer(source='part', many=False, read_only=True) location_detail = LocationBriefSerializer(source='location', many=False, read_only=True) quantity = InvenTreeDecimalField() # Annotated fields tracking_items = serializers.IntegerField(read_only=True, required=False) allocated = serializers.FloatField(required=False) expired = serializers.BooleanField(required=False, read_only=True) stale = serializers.BooleanField(required=False, read_only=True) purchase_price = InvenTree.serializers.InvenTreeMoneySerializer( label=_('Purchase Price'), max_digits=19, decimal_places=4, allow_null=True, help_text=_('Purchase price of this stock item'), ) purchase_price_currency = serializers.ChoiceField( choices=currency_code_mappings(), default=currency_code_default, label=_('Currency'), help_text=_('Purchase currency of this stock item'), ) purchase_price_string = serializers.SerializerMethodField() def get_purchase_price_string(self, obj): """Return purchase price as string.""" if obj.purchase_price: obj.purchase_price.decimal_places_display = 4 return str(obj.purchase_price) return '-' purchase_order_reference = serializers.CharField(source='purchase_order.reference', read_only=True) sales_order_reference = serializers.CharField(source='sales_order.reference', read_only=True) def __init__(self, *args, **kwargs): """Add detail fields.""" part_detail = kwargs.pop('part_detail', False) location_detail = kwargs.pop('location_detail', False) supplier_part_detail = kwargs.pop('supplier_part_detail', False) super(StockItemSerializer, self).__init__(*args, **kwargs) if part_detail is not True: self.fields.pop('part_detail') if location_detail is not True: self.fields.pop('location_detail') if supplier_part_detail is not True: self.fields.pop('supplier_part_detail') class Meta: """Metaclass options.""" model = StockItem fields = [ 'allocated', 'batch', 'belongs_to', 'build', 'customer', 'delete_on_deplete', 'expired', 'expiry_date', 'is_building', 'link', 'location', 'location_detail', 'notes', 'owner', 'packaging', 'part', 'part_detail', 'purchase_order', 'purchase_order_reference', 'pk', 'quantity', 'sales_order', 'sales_order_reference', 'serial', 'stale', 'status', 'status_text', 'stocktake_date', 'supplier_part', 'supplier_part_detail', 'tracking_items', 'uid', 'updated', 'purchase_price', 'purchase_price_currency', 'purchase_price_string', ] """ These fields are read-only in this context. They can be updated by accessing the appropriate API endpoints """ read_only_fields = [ 'allocated', 'stocktake_date', 'stocktake_user', 'updated', ] 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['item'] 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['item'] 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.getLatestSerialNumberInt()) 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): """Serialize stock item.""" item = self.context['item'] request = self.context['request'] user = request.user data = self.validated_data serials = InvenTree.helpers.extract_serial_numbers( data['serial_numbers'], data['quantity'], item.part.getLatestSerialNumberInt() ) item.serializeStock( data['quantity'], serials, user, notes=data.get('notes', ''), location=data['destination'], ) 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'), ) note = serializers.CharField( label=_('Note'), required=False, allow_blank=True, ) 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")) # Extract the "parent" item - the item into which the stock item will be installed parent_item = self.context['item'] parent_part = parent_item.part 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 save(self): """Install the selected stock item into this one.""" data = self.validated_data stock_item = data['stock_item'] note = data.get('note', '') parent_item = self.context['item'] request = self.context['request'] parent_item.installStockItem( stock_item, stock_item.quantity, 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['item'] 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 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) class ReturnStockItemSerializer(serializers.Serializer): """DRF serializer for returning a stock item from a customer""" 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 returned item'), ) notes = serializers.CharField( label=_('Notes'), help_text=_('Add transaction note (optional)'), required=False, allow_blank=True, ) def save(self): """Save the serialzier to return the item into stock""" item = self.context['item'] request = self.context['request'] data = self.validated_data location = data['location'] notes = data.get('notes', '') item.return_from_customer( location, user=request.user, notes=notes ) class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for a simple tree view.""" class Meta: """Metaclass options.""" model = StockLocation fields = [ 'pk', 'name', 'parent', 'icon', ] class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Detailed information about a stock location.""" @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.annotate( items=stock.filters.annotate_location_items() ) return queryset url = serializers.CharField(source='get_absolute_url', read_only=True) items = serializers.IntegerField(read_only=True) level = serializers.IntegerField(read_only=True) class Meta: """Metaclass options.""" model = StockLocation fields = [ 'pk', 'url', 'name', 'level', 'description', 'parent', 'pathstring', 'items', 'owner', 'icon', ] class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer): """Serializer for StockItemAttachment model.""" class Meta: """Metaclass options.""" model = StockItemAttachment fields = [ 'pk', 'stock_item', 'attachment', 'filename', 'link', 'comment', 'upload_date', 'user', 'user_detail', ] read_only_fields = [ 'upload_date', 'user', 'user_detail' ] class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for the StockItemTestResult model.""" user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True) key = serializers.CharField(read_only=True) attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=False) def __init__(self, *args, **kwargs): """Add detail fields.""" user_detail = kwargs.pop('user_detail', False) super().__init__(*args, **kwargs) if user_detail is not True: self.fields.pop('user_detail') class Meta: """Metaclass options.""" model = StockItemTestResult fields = [ 'pk', 'stock_item', 'key', 'test', 'result', 'value', 'attachment', 'notes', 'user', 'user_detail', 'date' ] read_only_fields = [ 'pk', 'user', 'date', ] class StockTrackingSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for StockItemTracking model.""" def __init__(self, *args, **kwargs): """Add detail fields.""" item_detail = kwargs.pop('item_detail', False) user_detail = kwargs.pop('user_detail', False) super().__init__(*args, **kwargs) if item_detail is not True: self.fields.pop('item_detail') if user_detail is not True: self.fields.pop('user_detail') label = serializers.CharField(read_only=True) item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True) user_detail = InvenTree.serializers.UserSerializer(source='user', many=False, read_only=True) deltas = serializers.JSONField(read_only=True) 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', ] 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) ) class StockAdjustmentItemSerializer(serializers.Serializer): """Serializer for a single StockItem within a stock adjument request. Fields: - item: StockItem object - quantity: Numerical quantity """ class Meta: """Metaclass options.""" fields = [ 'item', 'quantity' ] pk = serializers.PrimaryKeyRelatedField( queryset=StockItem.objects.all(), many=False, allow_null=False, required=True, label='stock_item', help_text=_('StockItem primary key value') ) quantity = serializers.DecimalField( max_digits=15, decimal_places=5, min_value=0, required=True ) 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'] stock_item.stocktake( quantity, request.user, notes=notes ) 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'] stock_item.add_stock( quantity, request.user, notes=notes ) 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'] stock_item.take_stock( quantity, request.user, notes=notes ) class StockTransferSerializer(StockAdjustmentSerializer): """Serializer for transferring (moving) stock item(s).""" location = serializers.PrimaryKeyRelatedField( queryset=StockLocation.objects.all(), many=False, required=True, allow_null=False, label=_('Location'), help_text=_('Destination stock location'), ) class Meta: """Metaclass options.""" fields = [ 'items', 'notes', '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: stock_item = item['pk'] quantity = item['quantity'] stock_item.move( location, notes, request.user, quantity=quantity )