InvenTree/InvenTree/stock/serializers.py
Miklós Márton 0716238f3b
Implement structural stock locations (#3949)
* Implement structural stock locations

* Bumped API version
2022-11-19 22:24:18 +11:00

1187 lines
32 KiB
Python

"""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',
'barcode_hash',
]
read_only_fields = [
'barcode_hash',
]
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=6,
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_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',
'barcode_hash',
'updated',
'purchase_price',
'purchase_price_currency',
]
"""
These fields are read-only in this context.
They can be updated by accessing the appropriate API endpoints
"""
read_only_fields = [
'allocated',
'barcode_hash',
'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.get_latest_serial_number()
)
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.get_latest_serial_number()
)
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',
'barcode_hash',
'url',
'name',
'level',
'description',
'parent',
'pathstring',
'items',
'owner',
'icon',
'structural',
]
read_only_fields = [
'barcode_hash',
]
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
)