2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-29 12:06:44 +00:00
InvenTree/InvenTree/stock/serializers.py
2022-05-15 20:33:00 +02:00

1109 lines
29 KiB
Python

"""
JSON serializers for Stock app
"""
from decimal import Decimal
from datetime import datetime, timedelta
from django.db import transaction
from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import gettext_lazy as _
from django.db.models.functions import Coalesce
from django.db.models import Case, When, Value
from django.db.models import BooleanField
from django.db.models import Q
from rest_framework import serializers
from rest_framework.serializers import ValidationError
from sql_util.utils import SubquerySum, SubqueryCount
from .models import StockItem, StockLocation
from .models import StockItemTracking
from .models import StockItemAttachment
from .models import StockItemTestResult
import common.models
from common.settings import currency_code_default, currency_code_mappings
import company.models
from company.serializers import SupplierPartSerializer
import InvenTree.helpers
import InvenTree.serializers
from InvenTree.serializers import InvenTreeDecimalField, extract_int
from part.serializers import PartBriefSerializer
class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""
Provides a brief serializer for a StockLocation object
"""
class Meta:
model = StockLocation
fields = [
'pk',
'name',
'pathstring',
]
class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
""" Brief serializers for a StockItem """
location_name = serializers.CharField(source='location', read_only=True)
part_name = serializers.CharField(source='part.full_name', read_only=True)
quantity = InvenTreeDecimalField()
class Meta:
model = StockItem
fields = [
'part',
'part_name',
'pk',
'location',
'location_name',
'quantity',
'serial',
'supplier_part',
'uid',
]
def validate_serial(self, value):
if extract_int(value) > 2147483647:
raise serializers.ValidationError('serial is to to big')
return value
class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
""" Serializer for a StockItem:
- Includes serialization for the linked part
- Includes serialization for the item location
"""
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.
"""
# 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)
tracking_items = serializers.IntegerField(source='tracking_info_count', read_only=True, required=False)
quantity = InvenTreeDecimalField()
allocated = serializers.FloatField(source='allocation_count', required=False)
expired = serializers.BooleanField(required=False, read_only=True)
stale = serializers.BooleanField(required=False, read_only=True)
# serial = serializers.CharField(required=False)
required_tests = serializers.IntegerField(source='required_test_count', read_only=True, required=False)
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 str(obj.purchase_price) if obj.purchase_price else '-'
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):
part_detail = kwargs.pop('part_detail', False)
location_detail = kwargs.pop('location_detail', False)
supplier_part_detail = kwargs.pop('supplier_part_detail', False)
test_detail = kwargs.pop('test_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')
if test_detail is not True:
self.fields.pop('required_tests')
class Meta:
model = StockItem
fields = [
'allocated',
'batch',
'belongs_to',
'build',
'customer',
'delete_on_deplete',
'expired',
'expiry_date',
'in_stock',
'is_building',
'link',
'location',
'location_detail',
'notes',
'owner',
'packaging',
'part',
'part_detail',
'purchase_order',
'purchase_order_reference',
'pk',
'quantity',
'required_tests',
'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',
'in_stock'
]
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:
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):
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:
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):
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 LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""
Serializer for a simple tree view
"""
class Meta:
model = StockLocation
fields = [
'pk',
'name',
'parent',
]
class LocationSerializer(InvenTree.serializers.InvenTreeModelSerializer):
""" Detailed information about a stock location
"""
url = serializers.CharField(source='get_absolute_url', read_only=True)
items = serializers.IntegerField(source='item_count', read_only=True)
level = serializers.IntegerField(read_only=True)
class Meta:
model = StockLocation
fields = [
'pk',
'url',
'name',
'level',
'description',
'parent',
'pathstring',
'items',
'owner',
]
class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSerializer):
""" Serializer for StockItemAttachment model """
def __init__(self, *args, **kwargs):
user_detail = kwargs.pop('user_detail', False)
super().__init__(*args, **kwargs)
if user_detail is not True:
self.fields.pop('user_detail')
user_detail = InvenTree.serializers.UserSerializerBrief(source='user', read_only=True)
# TODO: Record the uploading user when creating or updating an attachment!
class Meta:
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.UserSerializerBrief(source='user', read_only=True)
key = serializers.CharField(read_only=True)
attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=False)
def __init__(self, *args, **kwargs):
user_detail = kwargs.pop('user_detail', False)
super().__init__(*args, **kwargs)
if user_detail is not True:
self.fields.pop('user_detail')
class Meta:
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):
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.UserSerializerBrief(source='user', many=False, read_only=True)
deltas = serializers.JSONField(read_only=True)
class Meta:
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:
fields = [
'item',
]
item = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Stock Item'),
)
def validate_item(self, item):
# 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:
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):
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):
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):
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:
fields = [
'item',
]
item = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(),
many=False,
allow_null=False,
required=True,
label=_('Stock Item'),
)
def validate_item(self, item):
# 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:
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):
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:
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:
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):
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):
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):
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):
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:
fields = [
'items',
'notes',
'location',
]
def validate(self, data):
data = super().validate(data)
# TODO: Any specific validation of location field?
return data
def save(self):
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
)