Coverage for src/backend/InvenTree/stock/serializers.py: 45%
598 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""JSON serializers for Stock app."""
3from datetime import timedelta
4from decimal import Decimal
6from django.core.exceptions import ValidationError as DjangoValidationError
7from django.db import transaction
8from django.db.models import BooleanField, Case, Count, Prefetch, Q, Value, When
9from django.db.models.functions import Coalesce
10from django.utils.translation import gettext_lazy as _
12import structlog
13from drf_spectacular.utils import extend_schema_field
14from rest_framework import serializers
15from rest_framework.serializers import ValidationError
16from sql_util.utils import SubqueryCount, SubquerySum
18import build.models
19import common.filters
20import company.models
21import company.serializers as company_serializers
22import InvenTree.helpers
23import InvenTree.ready
24import InvenTree.serializers
25import order.models
26import part.filters as part_filters
27import part.models as part_models
28import part.serializers as part_serializers
29import stock.filters
30import stock.status_codes
31from common.settings import get_global_setting
32from generic.states.fields import InvenTreeCustomStatusSerializerMixin
33from importer.registry import register_importer
34from InvenTree.mixins import DataImportExportSerializerMixin
35from InvenTree.serializers import (
36 CustomStatusSerializerMixin,
37 InvenTreeCurrencySerializer,
38 InvenTreeDecimalField,
39 OptionalField,
40 TreePathSerializer,
41)
42from users.serializers import UserSerializer
44from .models import (
45 StockItem,
46 StockItemTestResult,
47 StockItemTracking,
48 StockLocation,
49 StockLocationType,
50)
52logger = structlog.get_logger('inventree')
55class GenerateBatchCodeSerializer(serializers.Serializer):
56 """Serializer for generating a batch code.
58 Any of the provided write-only fields can be used for additional context.
59 """
61 class Meta:
62 """Metaclass options."""
64 fields = [
65 'batch_code',
66 'build_order',
67 'item',
68 'location',
69 'part',
70 'purchase_order',
71 'quantity',
72 ]
74 read_only_fields = ['batch_code']
76 write_only_fields = [
77 'build_order',
78 'item',
79 'location',
80 'part',
81 'purchase_order',
82 'quantity',
83 ]
85 batch_code = serializers.CharField(
86 read_only=True, help_text=_('Generated batch code'), label=_('Batch Code')
87 )
89 build_order = serializers.PrimaryKeyRelatedField(
90 queryset=build.models.Build.objects.all(),
91 many=False,
92 required=False,
93 allow_null=True,
94 label=_('Build Order'),
95 help_text=_('Select build order'),
96 )
98 item = serializers.PrimaryKeyRelatedField(
99 queryset=StockItem.objects.all(),
100 many=False,
101 required=False,
102 allow_null=True,
103 label=_('Stock Item'),
104 help_text=_('Select stock item to generate batch code for'),
105 )
107 location = serializers.PrimaryKeyRelatedField(
108 queryset=StockLocation.objects.all(),
109 many=False,
110 required=False,
111 allow_null=True,
112 label=_('Location'),
113 help_text=_('Select location to generate batch code for'),
114 )
116 part = serializers.PrimaryKeyRelatedField(
117 queryset=part_models.Part.objects.all(),
118 many=False,
119 required=False,
120 allow_null=True,
121 label=_('Part'),
122 help_text=_('Select part to generate batch code for'),
123 )
125 purchase_order = serializers.PrimaryKeyRelatedField(
126 queryset=order.models.PurchaseOrder.objects.all(),
127 many=False,
128 required=False,
129 allow_null=True,
130 label=_('Purchase Order'),
131 help_text=_('Select purchase order'),
132 )
134 quantity = serializers.FloatField(
135 required=False,
136 allow_null=True,
137 label=_('Quantity'),
138 help_text=_('Enter quantity for batch code'),
139 )
142class GenerateSerialNumberSerializer(serializers.Serializer):
143 """Serializer for generating one or multiple serial numbers.
145 Any of the provided write-only fields can be used for additional context.
147 Note that in the case where multiple serial numbers are required,
148 the "serial_number" field will return a string with multiple serial numbers
149 separated by a comma.
150 """
152 class Meta:
153 """Metaclass options."""
155 fields = ['serial_number', 'part', 'quantity']
157 read_only_fields = ['serial_number']
159 write_only_fields = ['part', 'quantity']
161 serial_number = serializers.CharField(
162 read_only=True,
163 allow_null=True,
164 help_text=_('Generated serial number'),
165 label=_('Serial Number'),
166 )
168 part = serializers.PrimaryKeyRelatedField(
169 queryset=part_models.Part.objects.all(),
170 many=False,
171 required=False,
172 allow_null=True,
173 label=_('Part'),
174 help_text=_('Select part to generate serial number for'),
175 )
177 quantity = serializers.IntegerField(
178 required=False,
179 allow_null=False,
180 default=1,
181 label=_('Quantity'),
182 help_text=_('Quantity of serial numbers to generate'),
183 )
186class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
187 """Provides a brief serializer for a StockLocation object."""
189 class Meta:
190 """Metaclass options."""
192 model = StockLocation
193 fields = ['pk', 'name', 'pathstring']
196@register_importer()
197class StockItemTestResultSerializer(
198 InvenTree.serializers.FilterableSerializerMixin,
199 DataImportExportSerializerMixin,
200 InvenTree.serializers.InvenTreeModelSerializer,
201):
202 """Serializer for the StockItemTestResult model."""
204 class Meta:
205 """Metaclass options."""
207 model = StockItemTestResult
208 fields = [
209 'pk',
210 'stock_item',
211 'result',
212 'value',
213 'attachment',
214 'notes',
215 'test_station',
216 'started_datetime',
217 'finished_datetime',
218 'user',
219 'user_detail',
220 'date',
221 'template',
222 'template_detail',
223 ]
224 read_only_fields = ['pk']
226 def __init__(self, *args, **kwargs):
227 """Handle custom initialization for the serializer."""
228 super().__init__(*args, **kwargs)
230 if not self._is_importing: 230 ↛ exitline 230 didn't return from function '__init__' because the condition on line 230 was always true
231 # Unless we are importing data, mark the 'user' and 'date' fields as read-only
232 self.fields['user'].read_only = True
233 self.fields['date'].read_only = True
235 user_detail = OptionalField(
236 serializer_class=UserSerializer,
237 serializer_kwargs={'source': 'user', 'read_only': True, 'allow_null': True},
238 prefetch_fields=['user'],
239 )
241 template = serializers.PrimaryKeyRelatedField(
242 queryset=part_models.PartTestTemplate.objects.all(),
243 many=False,
244 required=False,
245 allow_null=True,
246 help_text=_('Template'),
247 label=_('Test template for this result'),
248 )
250 template_detail = OptionalField(
251 serializer_class=part_serializers.PartTestTemplateSerializer,
252 serializer_kwargs={'source': 'template', 'read_only': True, 'allow_null': True},
253 prefetch_fields=['template'],
254 )
256 attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(
257 required=False,
258 allow_null=True,
259 label=_('Attachment'),
260 help_text=_('Test result attachment'),
261 )
263 def validate(self, data):
264 """Validate the test result data."""
265 stock_item = data['stock_item']
266 template = data.get('template', None)
267 test_name = None
269 if not template:
270 # To support legacy API, we can accept a test name instead of a template
271 # In such a case, we use the test name to lookup the appropriate template
272 request_data = self.context['request'].data
274 if type(request_data) is list and len(request_data) > 0:
275 request_data = request_data[0]
277 test_name = request_data.get('test', test_name)
279 test_key = InvenTree.helpers.generateTestKey(test_name)
281 ancestors = stock_item.part.get_ancestors(include_self=True)
283 # Find a template based on name
284 if template := part_models.PartTestTemplate.objects.filter(
285 part__tree_id=stock_item.part.tree_id, part__in=ancestors, key=test_key
286 ).first():
287 data['template'] = template
288 else:
289 raise ValidationError({
290 'test': _('No matching test found for this part')
291 })
293 if not template:
294 raise ValidationError(_('Template ID or test name must be provided'))
296 data = super().validate(data)
298 started = data.get('started_datetime')
299 finished = data.get('finished_datetime')
301 if started is not None and finished is not None and started > finished:
302 raise ValidationError({
303 'finished_datetime': _(
304 'The test finished time cannot be earlier than the test started time'
305 )
306 })
307 return data
310@register_importer()
311class StockItemSerializer(
312 CustomStatusSerializerMixin,
313 InvenTree.serializers.FilterableSerializerMixin,
314 DataImportExportSerializerMixin,
315 InvenTreeCustomStatusSerializerMixin,
316 InvenTree.serializers.InvenTreeTaggitSerializer,
317 InvenTree.serializers.InvenTreeModelSerializer,
318):
319 """Serializer for a StockItem.
321 - Includes serialization for the linked part
322 - Includes serialization for the item location
323 """
325 export_exclude_fields = ['tags', 'tracking_items']
327 export_child_fields = [
328 'part_detail.name',
329 'part_detail.description',
330 'part_detail.IPN',
331 'part_detail.revision',
332 'part_detail.pricing_min',
333 'part_detail.pricing_max',
334 'location_detail.name',
335 'location_detail.pathstring',
336 'supplier_part_detail.SKU',
337 'supplier_part_detail.MPN',
338 ]
340 import_exclude_fields = ['location_path', 'serial_numbers', 'use_pack_size']
342 class Meta:
343 """Metaclass options."""
345 model = StockItem
346 fields = [
347 'pk',
348 'part',
349 'quantity',
350 'serial',
351 'batch',
352 'location',
353 'belongs_to',
354 'build',
355 'consumed_by',
356 'customer',
357 'delete_on_deplete',
358 'expiry_date',
359 'in_stock',
360 'is_building',
361 'link',
362 'notes',
363 'owner',
364 'packaging',
365 'parent',
366 'purchase_order',
367 'purchase_order_reference',
368 'sales_order',
369 'sales_order_reference',
370 'status',
371 'status_text',
372 'status_custom_key',
373 'supplier_part',
374 'SKU',
375 'MPN',
376 'barcode_hash',
377 'creation_date',
378 'stocktake_date',
379 'updated',
380 'purchase_price',
381 'purchase_price_currency',
382 'use_pack_size',
383 'serial_numbers',
384 # Annotated fields
385 'allocated',
386 'expired',
387 'installed_items',
388 'child_items',
389 'stale',
390 # Optional fields (FK relationships)
391 'location_detail',
392 'location_path',
393 'part_detail',
394 'supplier_part_detail',
395 'tags',
396 'tests',
397 'tracking_items',
398 ]
399 read_only_fields = [
400 'allocated',
401 'barcode_hash',
402 'creation_date',
403 'stocktake_date',
404 'stocktake_user',
405 'updated',
406 ]
407 """
408 These fields are read-only in this context.
409 They can be updated by accessing the appropriate API endpoints
410 """
411 extra_kwargs = {
412 'use_pack_size': {'write_only': True},
413 'serial_numbers': {'write_only': True},
414 }
415 """
416 Fields used when creating a stock item
417 """
419 part = serializers.PrimaryKeyRelatedField(
420 queryset=part_models.Part.objects.all(),
421 many=False,
422 allow_null=False,
423 help_text=_('Base Part'),
424 label=_('Part'),
425 )
427 parent = serializers.PrimaryKeyRelatedField(
428 many=False,
429 read_only=True,
430 required=False,
431 allow_null=True,
432 label=_('Parent Item'),
433 help_text=_('Parent stock item'),
434 )
436 location_path = OptionalField(
437 serializer_class=TreePathSerializer,
438 serializer_kwargs={
439 'source': 'location.get_path',
440 'extra_fields': ['icon'],
441 'many': True,
442 'read_only': True,
443 'allow_null': True,
444 },
445 default_include=False,
446 filter_name='path_detail',
447 )
449 in_stock = serializers.BooleanField(read_only=True, label=_('In Stock'))
451 """
452 Field used when creating a stock item
453 """
454 use_pack_size = serializers.BooleanField(
455 write_only=True,
456 required=False,
457 allow_null=True,
458 help_text=_(
459 'Use pack size when adding: the quantity defined is the number of packs'
460 ),
461 label=_('Use pack size'),
462 )
464 serial_numbers = serializers.CharField(
465 write_only=True,
466 required=False,
467 allow_null=True,
468 help_text=_('Enter serial numbers for new items'),
469 )
471 def validate_part(self, part):
472 """Ensure the provided Part instance is valid."""
473 if part.virtual:
474 raise ValidationError(_('Stock item cannot be created for virtual parts'))
476 return part
478 def update(self, instance, validated_data):
479 """Custom update method to pass the user information through to the instance."""
480 instance._user = self.context.get('user', None)
482 status_custom_key = validated_data.pop('status_custom_key', None)
483 status = validated_data.pop('status', None)
485 if status_code := status_custom_key or status:
486 # avoid a second .save() call and perform both status updates at once (to support `old_status` in tracking event)
487 # by setting the values in validated_data as computed by set_status()
488 instance.set_status(status_code)
489 validated_data['status'] = instance.status
490 validated_data['status_custom_key'] = (
491 status_code # for compatibility with custom "leader/follower" concept in super().update()
492 )
494 instance = super().update(instance, validated_data=validated_data)
496 return instance
498 @staticmethod
499 def annotate_queryset(queryset):
500 """Add some extra annotations to the queryset, performing database queries as efficiently as possible."""
501 queryset = queryset.prefetch_related(
502 'location',
503 'sales_order',
504 'purchase_order',
505 Prefetch(
506 'part',
507 queryset=part_models.Part.objects.annotate(
508 category_default_location=part_filters.annotate_default_location(
509 'category__'
510 )
511 ).prefetch_related(None),
512 ),
513 'parent',
514 'part__category',
515 'supplier_part',
516 'supplier_part__manufacturer_part',
517 'customer',
518 'belongs_to',
519 'sales_order',
520 'consumed_by',
521 ).select_related('part', 'part__pricing_data')
523 # Annotate the queryset with the total allocated to sales orders
524 queryset = queryset.annotate(
525 allocated=Coalesce(
526 SubquerySum('sales_order_allocations__quantity'), Decimal(0)
527 )
528 # For now, stock allocated to a transfer order will not impact its availability
529 # + Coalesce(SubquerySum('transfer_order_allocations__quantity'), Decimal(0))
530 + Coalesce(SubquerySum('allocations__quantity'), Decimal(0))
531 )
533 # Annotate the queryset with the number of tracking items
534 queryset = queryset.annotate(tracking_items=SubqueryCount('tracking_info'))
536 # Add flag to indicate if the StockItem has expired
537 queryset = queryset.annotate(
538 expired=Case(
539 When(
540 StockItem.get_expired_filter(),
541 then=Value(True, output_field=BooleanField()),
542 ),
543 default=Value(False, output_field=BooleanField()),
544 )
545 )
547 # Add flag to indicate if the StockItem is stale
548 stale_days = get_global_setting('STOCK_STALE_DAYS')
549 stale_date = InvenTree.helpers.current_date() + timedelta(days=stale_days)
550 stale_filter = (
551 StockItem.IN_STOCK_FILTER
552 & ~Q(expiry_date=None)
553 & Q(expiry_date__lt=stale_date)
554 )
556 queryset = queryset.annotate(
557 stale=Case(
558 When(stale_filter, then=Value(True, output_field=BooleanField())),
559 default=Value(False, output_field=BooleanField()),
560 )
561 )
563 # Annotate with the total number of "installed items"
564 queryset = queryset.annotate(installed_items=SubqueryCount('installed_parts'))
566 # Annotate with the total number of "child items" (split stock items)
567 queryset = queryset.annotate(child_items=SubqueryCount('children'))
569 return queryset
571 SKU = serializers.CharField(
572 source='supplier_part.SKU',
573 read_only=True,
574 label=_('Supplier Part Number'),
575 allow_null=True,
576 )
578 MPN = serializers.CharField(
579 source='supplier_part.manufacturer_part.MPN',
580 read_only=True,
581 label=_('Manufacturer Part Number'),
582 allow_null=True,
583 )
585 # Optional detail fields, which can be appended via query parameters
586 supplier_part_detail = OptionalField(
587 serializer_class=company_serializers.SupplierPartSerializer,
588 serializer_kwargs={
589 'label': _('Supplier Part'),
590 'source': 'supplier_part',
591 'brief': True,
592 'supplier_detail': False,
593 'manufacturer_detail': False,
594 'part_detail': False,
595 'many': False,
596 'read_only': True,
597 'allow_null': True,
598 },
599 default_include=False,
600 prefetch_fields=[
601 'supplier_part__supplier',
602 'supplier_part__purchase_order_line_items',
603 'supplier_part__manufacturer_part__manufacturer',
604 ],
605 )
607 part_detail = OptionalField(
608 serializer_class=part_serializers.PartBriefSerializer,
609 serializer_kwargs={
610 'label': _('Part'),
611 'source': 'part',
612 'many': False,
613 'read_only': True,
614 'allow_null': True,
615 },
616 default_include=True,
617 )
619 location_detail = OptionalField(
620 serializer_class=LocationBriefSerializer,
621 serializer_kwargs={
622 'label': _('Location'),
623 'source': 'location',
624 'many': False,
625 'read_only': True,
626 'allow_null': True,
627 },
628 default_include=False,
629 prefetch_fields=['location'],
630 )
632 tests = OptionalField(
633 serializer_class=StockItemTestResultSerializer,
634 serializer_kwargs={
635 'source': 'test_results',
636 'many': True,
637 'read_only': True,
638 'allow_null': True,
639 },
640 default_include=False,
641 prefetch_fields=[
642 'test_results',
643 'test_results__user',
644 'test_results__template',
645 ],
646 )
648 quantity = InvenTreeDecimalField()
650 # Annotated fields
651 allocated = serializers.FloatField(
652 read_only=True, allow_null=True, label=_('Allocated Quantity')
653 )
654 expired = serializers.BooleanField(
655 read_only=True, allow_null=True, label=_('Expired')
656 )
657 installed_items = serializers.IntegerField(
658 read_only=True, allow_null=True, label=_('Installed Items')
659 )
660 child_items = serializers.IntegerField(
661 read_only=True, allow_null=True, label=_('Child Items')
662 )
663 stale = serializers.BooleanField(read_only=True, allow_null=True, label=_('Stale'))
664 tracking_items = serializers.IntegerField(
665 read_only=True, allow_null=True, label=_('Tracking Items')
666 )
668 purchase_price = InvenTree.serializers.InvenTreeMoneySerializer(
669 label=_('Purchase Price'),
670 allow_null=True,
671 help_text=_('Purchase price of this stock item, per unit or pack'),
672 )
674 purchase_price_currency = InvenTreeCurrencySerializer(
675 help_text=_('Purchase currency of this stock item')
676 )
678 purchase_order_reference = serializers.CharField(
679 source='purchase_order.reference', read_only=True, allow_null=True
680 )
682 sales_order_reference = serializers.CharField(
683 source='sales_order.reference', read_only=True, allow_null=True
684 )
686 tags = common.filters.enable_tags_filter()
689class SerializeStockItemSerializer(serializers.Serializer):
690 """A DRF serializer for "serializing" a StockItem.
692 (Sorry for the confusing naming...)
694 Here, "serializing" means splitting out a single StockItem,
695 into multiple single-quantity items with an assigned serial number
697 Note: The base StockItem object is provided to the serializer context
698 """
700 class Meta:
701 """Metaclass options."""
703 fields = ['quantity', 'serial_numbers', 'destination', 'notes']
705 quantity = serializers.IntegerField(
706 min_value=0,
707 required=True,
708 label=_('Quantity'),
709 help_text=_('Enter number of stock items to serialize'),
710 )
712 def validate_quantity(self, quantity):
713 """Validate that the quantity value is correct."""
714 item = self.context.get('item')
716 if not item: 716 ↛ 719line 716 didn't jump to line 719 because the condition on line 716 was always true
717 raise ValidationError(_('No stock item provided'))
719 if quantity < 0:
720 raise ValidationError(_('Quantity must be greater than zero'))
722 if quantity > item.quantity:
723 q = item.quantity
724 raise ValidationError(
725 _(f'Quantity must not exceed available stock quantity ({q})')
726 )
728 return quantity
730 serial_numbers = serializers.CharField(
731 label=_('Serial Numbers'),
732 help_text=_('Enter serial numbers for new items'),
733 allow_blank=False,
734 required=True,
735 )
737 destination = serializers.PrimaryKeyRelatedField(
738 queryset=StockLocation.objects.all(),
739 many=False,
740 required=True,
741 allow_null=False,
742 label=_('Location'),
743 help_text=_('Destination stock location'),
744 )
746 notes = serializers.CharField(
747 required=False,
748 allow_blank=True,
749 label=_('Notes'),
750 help_text=_('Optional note field'),
751 )
753 def validate(self, data):
754 """Check that the supplied serial numbers are valid."""
755 data = super().validate(data)
757 item = self.context.get('item')
759 if not item:
760 raise ValidationError(_('No stock item provided'))
762 if not item.part.trackable:
763 raise ValidationError(_('Serial numbers cannot be assigned to this part'))
765 # Ensure the serial numbers are valid!
766 quantity = data['quantity']
767 serial_numbers = data['serial_numbers']
769 try:
770 serials = InvenTree.helpers.extract_serial_numbers(
771 serial_numbers,
772 quantity,
773 item.part.get_latest_serial_number(),
774 part=item.part,
775 )
776 except DjangoValidationError as e:
777 raise ValidationError({'serial_numbers': e.messages})
779 existing = item.part.find_conflicting_serial_numbers(serials)
781 if len(existing) > 0:
782 exists = ','.join([str(x) for x in existing])
783 error = _('Serial numbers already exist') + ': ' + exists
785 raise ValidationError({'serial_numbers': error})
787 return data
789 def save(self) -> list[StockItem]:
790 """Serialize the provided StockItem.
792 Returns:
793 A list of StockItem objects that were created as a result of the serialization.
794 """
795 item = self.context.get('item')
797 if not item:
798 raise ValidationError(_('No stock item provided'))
800 request = self.context.get('request')
801 user = request.user if request else None
803 data = self.validated_data
805 serials = InvenTree.helpers.extract_serial_numbers(
806 data['serial_numbers'],
807 data['quantity'],
808 item.part.get_latest_serial_number(),
809 part=item.part,
810 )
812 return (
813 item.serializeStock(
814 data['quantity'],
815 serials,
816 user=user,
817 notes=data.get('notes', ''),
818 location=data['destination'],
819 )
820 or []
821 )
824class InstallStockItemSerializer(serializers.Serializer):
825 """Serializer for installing a stock item into a given part."""
827 stock_item = serializers.PrimaryKeyRelatedField(
828 queryset=StockItem.objects.all(),
829 many=False,
830 required=True,
831 allow_null=False,
832 label=_('Stock Item'),
833 help_text=_('Select stock item to install'),
834 )
836 quantity = serializers.IntegerField(
837 min_value=1,
838 default=1,
839 required=False,
840 label=_('Quantity to Install'),
841 help_text=_('Enter the quantity of items to install'),
842 )
844 note = serializers.CharField(
845 label=_('Note'),
846 help_text=_('Add transaction note (optional)'),
847 required=False,
848 allow_blank=True,
849 )
851 def validate_quantity(self, quantity):
852 """Validate the quantity value."""
853 if quantity < 1: 853 ↛ 854line 853 didn't jump to line 854 because the condition on line 853 was never true
854 raise ValidationError(_('Quantity to install must be at least 1'))
856 return quantity
858 def validate_stock_item(self, stock_item):
859 """Validate the selected stock item."""
860 if not stock_item.in_stock:
861 # StockItem must be in stock to be "installed"
862 raise ValidationError(_('Stock item is unavailable'))
864 parent_item = self.context['item']
865 parent_part = parent_item.part
867 if get_global_setting(
868 'STOCK_ENFORCE_BOM_INSTALLATION', backup_value=True, cache=False
869 ):
870 # Check if the selected part is in the Bill of Materials of the parent item
871 if not parent_part.check_if_part_in_bom(stock_item.part):
872 raise ValidationError(
873 _('Selected part is not in the Bill of Materials')
874 )
876 return stock_item
878 def validate(self, data):
879 """Ensure that the provided dataset is valid."""
880 stock_item = data['stock_item']
882 quantity = data.get('quantity', stock_item.quantity)
884 if quantity > stock_item.quantity:
885 raise ValidationError({
886 'quantity': _('Quantity to install must not exceed available quantity')
887 })
889 return data
891 def save(self):
892 """Install the selected stock item into this one."""
893 data = self.validated_data
895 stock_item = data['stock_item']
896 quantity_to_install = data.get('quantity', stock_item.quantity)
897 note = data.get('note', '')
899 parent_item = self.context['item']
900 request = self.context['request']
902 parent_item.installStockItem(
903 stock_item, quantity_to_install, request.user, note
904 )
907class UninstallStockItemSerializer(serializers.Serializer):
908 """API serializers for uninstalling an installed item from a stock item."""
910 class Meta:
911 """Metaclass options."""
913 fields = ['location', 'note']
915 location = serializers.PrimaryKeyRelatedField(
916 queryset=StockLocation.objects.all(),
917 many=False,
918 required=True,
919 allow_null=False,
920 label=_('Location'),
921 help_text=_('Destination location for uninstalled item'),
922 )
924 note = serializers.CharField(
925 label=_('Notes'),
926 help_text=_('Add transaction note (optional)'),
927 required=False,
928 allow_blank=True,
929 )
931 def save(self):
932 """Uninstall stock item."""
933 item = self.context.get('item')
935 if not item:
936 raise ValidationError(_('No stock item provided'))
938 data = self.validated_data
939 request = self.context['request']
941 location = data['location']
943 note = data.get('note', '')
945 item.uninstall_into_location(location, request.user, note)
948class ConvertStockItemSerializer(serializers.Serializer):
949 """DRF serializer class for converting a StockItem to a valid variant part."""
951 class Meta:
952 """Metaclass options."""
954 fields = ['part']
956 part = serializers.PrimaryKeyRelatedField(
957 queryset=part_models.Part.objects.all(),
958 label=_('Part'),
959 help_text=_('Select part to convert stock item into'),
960 many=False,
961 required=True,
962 allow_null=False,
963 )
965 def validate_part(self, part):
966 """Ensure that the provided part is a valid option for the stock item."""
967 stock_item = self.context['item']
968 valid_options = stock_item.part.get_conversion_options()
970 if part not in valid_options:
971 raise ValidationError(
972 _('Selected part is not a valid option for conversion')
973 )
975 return part
977 def validate(self, data):
978 """Ensure that the stock item is valid for conversion.
980 Rules:
981 - If a SupplierPart is assigned, we cannot convert!
982 """
983 data = super().validate(data)
985 stock_item = self.context['item']
987 if stock_item.supplier_part is not None:
988 raise ValidationError(
989 _('Cannot convert stock item with assigned SupplierPart')
990 )
992 return data
994 def save(self):
995 """Save the serializer to convert the StockItem to the selected Part."""
996 data = self.validated_data
998 part = data['part']
1000 stock_item = self.context['item']
1001 request = self.context['request']
1003 stock_item.convert_to_variant(part, request.user)
1006@extend_schema_field(
1007 serializers.IntegerField(
1008 help_text='Status key, chosen from the list of StockStatus keys'
1009 )
1010)
1011class StockStatusCustomSerializer(serializers.ChoiceField):
1012 """Serializer to allow annotating the schema to use int where custom values may be entered."""
1014 def __init__(self, *args, **kwargs):
1015 """Initialize the status selector."""
1016 if 'choices' not in kwargs:
1017 kwargs['choices'] = stock.status_codes.StockStatus.items(custom=True)
1019 if 'label' not in kwargs: 1019 ↛ 1022line 1019 didn't jump to line 1022 because the condition on line 1019 was always true
1020 kwargs['label'] = _('Status')
1022 if 'help_text' not in kwargs: 1022 ↛ 1025line 1022 didn't jump to line 1025 because the condition on line 1022 was always true
1023 kwargs['help_text'] = _('Stock item status code')
1025 if InvenTree.ready.isGeneratingSchema(): 1025 ↛ 1026line 1025 didn't jump to line 1026 because the condition on line 1025 was never true
1026 kwargs['help_text'] = (
1027 kwargs['help_text']
1028 + '\n\n'
1029 + '\n'.join(
1030 f'* `{value}` - {label}' for value, label in kwargs['choices']
1031 )
1032 + "\n\nAdditional custom status keys may be retrieved from the 'stock_status_retrieve' call."
1033 )
1035 super().__init__(*args, **kwargs)
1038class StockChangeStatusSerializer(serializers.Serializer):
1039 """Serializer for changing status of multiple StockItem objects."""
1041 class Meta:
1042 """Metaclass options."""
1044 fields = ['items', 'status', 'note']
1046 items = serializers.PrimaryKeyRelatedField(
1047 queryset=StockItem.objects.all(),
1048 many=True,
1049 required=True,
1050 allow_null=False,
1051 label=_('Stock Items'),
1052 help_text=_('Select stock items to change status'),
1053 )
1055 def validate_items(self, items):
1056 """Validate the selected stock items."""
1057 if len(items) == 0: 1057 ↛ 1060line 1057 didn't jump to line 1060 because the condition on line 1057 was always true
1058 raise ValidationError(_('No stock items selected'))
1060 return items
1062 status = StockStatusCustomSerializer(
1063 default=stock.status_codes.StockStatus.OK.value
1064 )
1066 note = serializers.CharField(
1067 label=_('Notes'),
1068 help_text=_('Add transaction note (optional)'),
1069 required=False,
1070 allow_blank=True,
1071 )
1073 @transaction.atomic
1074 def save(self):
1075 """Save the serializer to change the status of the selected stock items."""
1076 data = self.validated_data
1078 items = data['items']
1079 status = data['status']
1081 request = self.context['request']
1082 user = getattr(request, 'user', None)
1084 note = data.get('note', '')
1086 transaction_notes = []
1088 now = InvenTree.helpers.current_time()
1090 # Instead of performing database updates for each item,
1091 # perform bulk database updates (much more efficient)
1093 # Pre-cache the custom status values (to reduce DB hits)
1094 custom_status_codes = StockItem.STATUS_CLASS.custom_values()
1096 for item in items:
1097 # Ignore items which are already in the desired status
1099 # Careful check for custom status codes also
1100 if item.compare_status(status):
1101 custom_status = item.get_custom_status()
1102 if status == custom_status or custom_status is None:
1103 continue
1105 deltas = {'status': status}
1107 # before save, track old status logical
1108 deltas['old_status_logical'] = item.status
1110 if item.get_custom_status():
1111 deltas['old_status'] = item.get_custom_status()
1112 else:
1113 deltas['old_status'] = item.status
1115 item.set_status(status, custom_values=custom_status_codes)
1116 item.save(add_note=False)
1118 # after save, can track new status_logical
1119 deltas['status_logical'] = item.status
1121 # Create a new transaction note for each item
1122 transaction_notes.append(
1123 StockItemTracking(
1124 item=item,
1125 tracking_type=stock.status_codes.StockHistoryCode.EDITED.value,
1126 date=now,
1127 deltas=deltas,
1128 user=user,
1129 notes=note,
1130 )
1131 )
1133 # Create tracking entries
1134 StockItemTracking.objects.bulk_create(transaction_notes, batch_size=250)
1137class StockLocationTypeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
1138 """Serializer for StockLocationType model."""
1140 class Meta:
1141 """Serializer metaclass."""
1143 model = StockLocationType
1144 fields = ['pk', 'name', 'description', 'icon', 'location_count']
1146 read_only_fields = ['location_count']
1148 location_count = serializers.IntegerField(read_only=True, allow_null=True)
1150 @staticmethod
1151 def annotate_queryset(queryset):
1152 """Add location count to each location type."""
1153 return queryset.annotate(location_count=Count('stock_locations'))
1156class LocationTreeSerializer(InvenTree.serializers.InvenTreeModelSerializer):
1157 """Serializer for a simple tree view."""
1159 class Meta:
1160 """Metaclass options."""
1162 model = StockLocation
1163 fields = [
1164 'pk',
1165 'name',
1166 'description',
1167 'pathstring',
1168 'parent',
1169 'tree_id',
1170 'level',
1171 'icon',
1172 'structural',
1173 'sublocations',
1174 ]
1176 sublocations = serializers.IntegerField(label=_('Sublocations'), read_only=True)
1178 @staticmethod
1179 def annotate_queryset(queryset):
1180 """Annotate the queryset with the number of sublocations."""
1181 return queryset.annotate(sublocations=stock.filters.annotate_sub_locations())
1184class LocationDeleteSerializer(serializers.Serializer):
1185 """Serializer for deleting a stock location."""
1187 class Meta:
1188 """Metaclass options."""
1190 fields = ['delete_stock_items', 'delete_sub_locations']
1192 delete_stock_items = serializers.BooleanField(
1193 required=True,
1194 label=_('Delete Stock Items'),
1195 help_text=_('Delete all stock items contained within this location'),
1196 )
1198 delete_sub_locations = serializers.BooleanField(
1199 required=True,
1200 label=_('Delete Sublocations'),
1201 help_text=_('Delete all sub-locations contained within this location'),
1202 )
1205@register_importer()
1206class LocationSerializer(
1207 InvenTree.serializers.FilterableSerializerMixin,
1208 DataImportExportSerializerMixin,
1209 InvenTree.serializers.InvenTreeTaggitSerializer,
1210 InvenTree.serializers.InvenTreeModelSerializer,
1211):
1212 """Detailed information about a stock location."""
1214 import_exclude_fields = []
1216 class Meta:
1217 """Metaclass options."""
1219 model = StockLocation
1220 fields = [
1221 'pk',
1222 'barcode_hash',
1223 'name',
1224 'level',
1225 'description',
1226 'parent',
1227 'pathstring',
1228 'path',
1229 'items',
1230 'sublocations',
1231 'owner',
1232 'icon',
1233 'custom_icon',
1234 'structural',
1235 'external',
1236 'location_type',
1237 # Optional fields
1238 'location_type_detail',
1239 'tags',
1240 'parameters',
1241 ]
1242 read_only_fields = ['barcode_hash', 'icon', 'level', 'pathstring']
1244 @staticmethod
1245 def annotate_queryset(queryset):
1246 """Annotate extra information to the queryset."""
1247 # Annotate the number of stock items which exist in this category (including subcategories)
1249 queryset = queryset.prefetch_related('tags')
1251 queryset = queryset.annotate(
1252 items=stock.filters.annotate_location_items(),
1253 sublocations=stock.filters.annotate_sub_locations(),
1254 )
1256 return queryset
1258 parent = serializers.PrimaryKeyRelatedField(
1259 queryset=StockLocation.objects.all(),
1260 many=False,
1261 allow_null=True,
1262 required=False,
1263 label=_('Parent Location'),
1264 help_text=_('Parent stock location'),
1265 )
1267 items = serializers.IntegerField(read_only=True, label=_('Stock Items'))
1269 sublocations = serializers.IntegerField(read_only=True, label=_('Sublocations'))
1271 level = serializers.IntegerField(read_only=True)
1273 tags = common.filters.enable_tags_filter()
1275 path = OptionalField(
1276 serializer_class=TreePathSerializer,
1277 serializer_kwargs={
1278 'many': True,
1279 'source': 'get_path',
1280 'extra_fields': ['icon'],
1281 'read_only': True,
1282 'allow_null': True,
1283 },
1284 default_include=False,
1285 filter_name='path_detail',
1286 )
1288 parameters = common.filters.enable_parameters_filter()
1290 # explicitly set this field, so it gets included for AutoSchema
1291 icon = serializers.CharField(read_only=True)
1293 # Detail for location type
1294 location_type_detail = StockLocationTypeSerializer(
1295 source='location_type', read_only=True, allow_null=True, many=False
1296 )
1299@register_importer()
1300class StockTrackingSerializer(
1301 InvenTree.serializers.FilterableSerializerMixin,
1302 DataImportExportSerializerMixin,
1303 InvenTree.serializers.InvenTreeModelSerializer,
1304):
1305 """Serializer for StockItemTracking model."""
1307 class Meta:
1308 """Metaclass options."""
1310 model = StockItemTracking
1311 fields = [
1312 'pk',
1313 'item',
1314 'item_detail',
1315 'part',
1316 'part_detail',
1317 'date',
1318 'deltas',
1319 'label',
1320 'notes',
1321 'tracking_type',
1322 'user',
1323 'user_detail',
1324 ]
1325 read_only_fields = ['date', 'part', 'user', 'label', 'tracking_type']
1327 label = serializers.CharField(read_only=True)
1329 item_detail = OptionalField(
1330 serializer_class=StockItemSerializer,
1331 serializer_kwargs={
1332 'source': 'item',
1333 'many': False,
1334 'read_only': True,
1335 'allow_null': True,
1336 },
1337 prefetch_fields=['item', 'item__part'],
1338 )
1340 part_detail = OptionalField(
1341 serializer_class=part_serializers.PartBriefSerializer,
1342 serializer_kwargs={
1343 'source': 'part',
1344 'many': False,
1345 'read_only': True,
1346 'allow_null': True,
1347 },
1348 default_include=False,
1349 prefetch_fields=['part'],
1350 )
1352 user_detail = OptionalField(
1353 serializer_class=UserSerializer,
1354 serializer_kwargs={
1355 'source': 'user',
1356 'many': False,
1357 'read_only': True,
1358 'allow_null': True,
1359 },
1360 prefetch_fields=['user'],
1361 )
1363 deltas = serializers.JSONField(read_only=True)
1366class StockAssignmentItemSerializer(serializers.Serializer):
1367 """Serializer for a single StockItem with in StockAssignment request.
1369 Here, the particular StockItem is being assigned (manually) to a customer
1371 Fields:
1372 - item: StockItem object
1373 """
1375 class Meta:
1376 """Metaclass options."""
1378 fields = ['item']
1380 item = serializers.PrimaryKeyRelatedField(
1381 queryset=StockItem.objects.all(),
1382 many=False,
1383 allow_null=False,
1384 required=True,
1385 label=_('Stock Item'),
1386 )
1388 def validate_item(self, item):
1389 """Validate item.
1391 Ensures:
1392 - is in stock
1393 - Is salable
1394 - Is not allocated
1395 """
1396 # The item must currently be "in stock"
1397 if not item.in_stock:
1398 raise ValidationError(_('Item must be in stock'))
1400 # The base part must be "salable"
1401 if not item.part.salable:
1402 raise ValidationError(_('Part must be salable'))
1404 # The item must not be allocated to a sales order
1405 if item.sales_order_allocations.count() > 0:
1406 raise ValidationError(_('Item is allocated to a sales order'))
1408 # The item must not be allocated to a transfer order
1409 if item.transfer_order_allocations.count() > 0:
1410 raise ValidationError(_('Item is allocated to a transfer order'))
1412 # The item must not be allocated to a build order
1413 if item.allocations.count() > 0:
1414 raise ValidationError(_('Item is allocated to a build order'))
1416 return item
1419class StockAssignmentSerializer(serializers.Serializer):
1420 """Serializer for assigning one (or more) stock items to a customer.
1422 This is a manual assignment process, separate for (for example) a Sales Order
1423 """
1425 class Meta:
1426 """Metaclass options."""
1428 fields = ['items', 'customer', 'notes']
1430 items = StockAssignmentItemSerializer(many=True, required=True)
1432 customer = serializers.PrimaryKeyRelatedField(
1433 queryset=company.models.Company.objects.all(),
1434 many=False,
1435 allow_null=False,
1436 required=True,
1437 label=_('Customer'),
1438 help_text=_('Customer to assign stock items'),
1439 )
1441 def validate_customer(self, customer):
1442 """Make sure provided company is customer."""
1443 if customer and not customer.is_customer:
1444 raise ValidationError(_('Selected company is not a customer'))
1446 return customer
1448 notes = serializers.CharField(
1449 required=False,
1450 allow_blank=True,
1451 label=_('Notes'),
1452 help_text=_('Stock assignment notes'),
1453 )
1455 def validate(self, data):
1456 """Make sure items were provided."""
1457 data = super().validate(data)
1459 items = data.get('items', [])
1461 if len(items) == 0:
1462 raise ValidationError(_('A list of stock items must be provided'))
1464 return data
1466 def save(self):
1467 """Assign stock."""
1468 request = self.context['request']
1470 user = getattr(request, 'user', None)
1472 data = self.validated_data
1474 items = data['items']
1475 customer = data['customer']
1476 notes = data.get('notes', '')
1478 with transaction.atomic():
1479 for item in items:
1480 stock_item = item['item']
1482 stock_item.allocateToCustomer(customer, user=user, notes=notes)
1485class StockMergeItemSerializer(serializers.Serializer):
1486 """Serializer for a single StockItem within the StockMergeSerializer class.
1488 Here, the individual StockItem is being checked for merge compatibility.
1489 """
1491 class Meta:
1492 """Metaclass options."""
1494 fields = ['item']
1496 item = serializers.PrimaryKeyRelatedField(
1497 queryset=StockItem.objects.all(),
1498 many=False,
1499 allow_null=False,
1500 required=True,
1501 label=_('Stock Item'),
1502 )
1504 def validate_item(self, item):
1505 """Make sure item can be merged."""
1506 # Check that the stock item is able to be merged
1507 item.can_merge(raise_error=True)
1509 return item
1512class StockMergeSerializer(serializers.Serializer):
1513 """Serializer for merging two (or more) stock items together."""
1515 class Meta:
1516 """Metaclass options."""
1518 fields = [
1519 'items',
1520 'location',
1521 'notes',
1522 'allow_mismatched_suppliers',
1523 'allow_mismatched_status',
1524 ]
1526 items = StockMergeItemSerializer(many=True, required=True)
1528 location = serializers.PrimaryKeyRelatedField(
1529 queryset=StockLocation.objects.all(),
1530 many=False,
1531 required=True,
1532 allow_null=False,
1533 label=_('Location'),
1534 help_text=_('Destination stock location'),
1535 )
1537 notes = serializers.CharField(
1538 required=False,
1539 allow_blank=True,
1540 label=_('Notes'),
1541 help_text=_('Stock merging notes'),
1542 )
1544 allow_mismatched_suppliers = serializers.BooleanField(
1545 required=False,
1546 label=_('Allow mismatched suppliers'),
1547 help_text=_('Allow stock items with different supplier parts to be merged'),
1548 )
1550 allow_mismatched_status = serializers.BooleanField(
1551 required=False,
1552 label=_('Allow mismatched status'),
1553 help_text=_('Allow stock items with different status codes to be merged'),
1554 )
1556 def validate(self, data):
1557 """Make sure all needed values are provided and that the items can be merged."""
1558 data = super().validate(data)
1560 items = data['items']
1562 if len(items) < 2:
1563 raise ValidationError(_('At least two stock items must be provided'))
1565 unique_items = set()
1567 # The "base item" is the first item
1568 base_item = items[0]['item']
1570 data['base_item'] = base_item
1572 # Ensure stock items are unique!
1573 for element in items:
1574 item = element['item']
1576 if item.pk in unique_items:
1577 raise ValidationError(_('Duplicate stock items'))
1579 unique_items.add(item.pk)
1581 # Checks from here refer to the "base_item"
1582 if item == base_item:
1583 continue
1585 # Check that this item can be merged with the base_item
1586 item.can_merge(
1587 raise_error=True,
1588 other=base_item,
1589 allow_mismatched_suppliers=data.get(
1590 'allow_mismatched_suppliers', False
1591 ),
1592 allow_mismatched_status=data.get('allow_mismatched_status', False),
1593 )
1595 return data
1597 def save(self):
1598 """Actually perform the stock merging action.
1600 At this point we are confident that the merge can take place
1601 """
1602 data = self.validated_data
1604 base_item = data['base_item']
1605 items = data['items'][1:]
1607 request = self.context['request']
1608 user = getattr(request, 'user', None)
1610 items = []
1612 for item in data['items'][1:]:
1613 items.append(item['item'])
1615 base_item.merge_stock_items(
1616 items,
1617 allow_mismatched_suppliers=data.get('allow_mismatched_suppliers', False),
1618 allow_mismatched_status=data.get('allow_mismatched_status', False),
1619 user=user,
1620 location=data['location'],
1621 notes=data.get('notes', None),
1622 )
1625def stock_item_adjust_status_options():
1626 """Return a custom set of options for the StockItem status adjustment field.
1628 In particular, include a Null option for the status field.
1629 """
1630 return [(None, _('No Change')), *stock.status_codes.StockStatus.items(custom=True)]
1633class StockAdjustmentItemSerializer(serializers.Serializer):
1634 """Serializer for a single StockItem within a stock adjustment request.
1636 Required Fields:
1637 - item: StockItem object
1638 - quantity: Numerical quantity
1640 Optional Fields (may be used by external tools)
1641 - status: Change StockItem status code
1642 - packaging: Change StockItem packaging
1643 - batch: Change StockItem batch code
1645 The optional fields can be used to adjust values for individual stock items
1646 """
1648 class Meta:
1649 """Metaclass options."""
1651 fields = ['pk', 'quantity', 'batch', 'status', 'packaging']
1653 def __init__(self, *args, **kwargs):
1654 """Initialize the serializer."""
1655 # Store the 'require_in_stock' status
1656 # Either True / False / None
1657 self.require_in_stock = kwargs.pop('require_in_stock', True)
1658 self.require_non_zero = kwargs.pop('require_non_zero', False)
1660 super().__init__(*args, **kwargs)
1662 pk = serializers.PrimaryKeyRelatedField(
1663 queryset=StockItem.objects.all(),
1664 many=False,
1665 allow_null=False,
1666 required=True,
1667 label='stock_item',
1668 help_text=_('StockItem primary key value'),
1669 )
1671 def validate_pk(self, stock_item: StockItem) -> StockItem:
1672 """Ensure the stock item is valid."""
1673 if self.require_in_stock == True:
1674 allow_out_of_stock_transfer = get_global_setting(
1675 'STOCK_ALLOW_OUT_OF_STOCK_TRANSFER', backup_value=False, cache=False
1676 )
1678 if not allow_out_of_stock_transfer and not stock_item.is_in_stock(
1679 check_status=False, check_quantity=False, check_in_production=False
1680 ):
1681 raise ValidationError(_('Stock item is not in stock'))
1682 elif self.require_in_stock == False:
1683 if stock_item.is_in_stock():
1684 raise ValidationError(_('Stock item is already in stock'))
1686 return stock_item
1688 quantity = serializers.DecimalField(
1689 max_digits=15, decimal_places=5, min_value=Decimal(0), required=True
1690 )
1692 def validate_quantity(self, quantity):
1693 """Validate the quantity value."""
1694 if self.require_non_zero and quantity <= 0: 1694 ↛ 1695line 1694 didn't jump to line 1695 because the condition on line 1694 was never true
1695 raise ValidationError(_('Quantity must be greater than zero'))
1697 if quantity < 0: 1697 ↛ 1698line 1697 didn't jump to line 1698 because the condition on line 1697 was never true
1698 raise ValidationError(_('Quantity must not be negative'))
1700 return quantity
1702 batch = serializers.CharField(
1703 max_length=100,
1704 required=False,
1705 allow_blank=True,
1706 label=_('Batch Code'),
1707 help_text=_('Batch code for this stock item'),
1708 )
1710 status = StockStatusCustomSerializer(
1711 choices=stock_item_adjust_status_options(),
1712 default=None,
1713 required=False,
1714 allow_blank=True,
1715 )
1717 packaging = serializers.CharField(
1718 max_length=50,
1719 required=False,
1720 allow_blank=True,
1721 label=_('Packaging'),
1722 help_text=_('Packaging this stock item is stored in'),
1723 )
1726class StockAdjustmentSerializer(serializers.Serializer):
1727 """Base class for managing stock adjustment actions via the API."""
1729 class Meta:
1730 """Metaclass options."""
1732 fields = ['items', 'notes']
1734 items = StockAdjustmentItemSerializer(many=True)
1736 notes = serializers.CharField(
1737 required=False,
1738 allow_blank=True,
1739 label=_('Notes'),
1740 help_text=_('Stock transaction notes'),
1741 )
1743 def validate(self, data):
1744 """Make sure items are provided."""
1745 super().validate(data)
1747 items = data.get('items', [])
1749 if len(items) == 0: 1749 ↛ 1752line 1749 didn't jump to line 1752 because the condition on line 1749 was always true
1750 raise ValidationError(_('A list of stock items must be provided'))
1752 return data
1755class StockCountSerializer(StockAdjustmentSerializer):
1756 """Serializer for counting stock items."""
1758 class Meta:
1759 """Metaclass options."""
1761 fields = ['items', 'notes', 'location']
1763 location = serializers.PrimaryKeyRelatedField(
1764 queryset=StockLocation.objects.filter(structural=False),
1765 many=False,
1766 required=False,
1767 allow_null=True,
1768 label=_('Location'),
1769 help_text=_('Set stock location for counted items (optional)'),
1770 )
1772 def save(self):
1773 """Count stock."""
1774 request = self.context['request']
1776 data = self.validated_data
1777 items = data['items']
1778 notes = data.get('notes', '')
1779 location = data.get('location', None)
1781 with transaction.atomic():
1782 for item in items:
1783 stock_item = item['pk']
1784 quantity = item['quantity']
1786 # Optional fields
1787 extra = {}
1789 for field_name in StockItem.optional_transfer_fields():
1790 if field_value := item.get(field_name, None):
1791 extra[field_name] = field_value
1793 if location is not None:
1794 extra['location'] = location
1796 stock_item.stocktake(quantity, request.user, notes=notes, **extra)
1799class StockAddSerializer(StockAdjustmentSerializer):
1800 """Serializer for adding stock to stock item(s)."""
1802 def save(self):
1803 """Add stock."""
1804 request = self.context['request']
1806 data = self.validated_data
1807 notes = data.get('notes', '')
1809 with transaction.atomic():
1810 for item in data['items']:
1811 stock_item = item['pk']
1812 quantity = item['quantity']
1814 if quantity is None or quantity <= 0:
1815 # Ignore in this case - no stock to add
1816 continue
1818 # Optional fields
1819 extra = {}
1821 for field_name in StockItem.optional_transfer_fields():
1822 if field_value := item.get(field_name, None):
1823 extra[field_name] = field_value
1825 stock_item.add_stock(quantity, request.user, notes=notes, **extra)
1828class StockRemoveSerializer(StockAdjustmentSerializer):
1829 """Serializer for removing stock from stock item(s)."""
1831 def save(self):
1832 """Remove stock."""
1833 request = self.context['request']
1835 data = self.validated_data
1836 notes = data.get('notes', '')
1838 with transaction.atomic():
1839 for item in data['items']:
1840 stock_item = item['pk']
1841 quantity = item['quantity']
1843 # Ignore in this case - no stock to remove
1844 if quantity is None or quantity <= 0:
1845 continue
1847 # Optional fields
1848 extra = {}
1850 for field_name in StockItem.optional_transfer_fields():
1851 if field_value := item.get(field_name, None):
1852 extra[field_name] = field_value
1854 stock_item.take_stock(quantity, request.user, notes=notes, **extra)
1857class StockTransferSerializer(StockAdjustmentSerializer):
1858 """Serializer for transferring (moving) stock item(s)."""
1860 class Meta:
1861 """Metaclass options."""
1863 fields = ['items', 'notes', 'location']
1865 items = StockAdjustmentItemSerializer(many=True, require_non_zero=True)
1867 location = serializers.PrimaryKeyRelatedField(
1868 queryset=StockLocation.objects.filter(structural=False),
1869 many=False,
1870 required=True,
1871 allow_null=False,
1872 label=_('Location'),
1873 help_text=_('Destination stock location'),
1874 )
1876 def save(self):
1877 """Transfer stock."""
1878 request = self.context['request']
1880 data = self.validated_data
1882 items = data['items']
1883 notes = data.get('notes', '')
1884 location = data['location']
1886 with transaction.atomic():
1887 for item in items:
1888 # Required fields
1889 stock_item = item['pk']
1890 quantity = item['quantity']
1892 # Optional fields
1893 kwargs = {}
1895 for field_name in StockItem.optional_transfer_fields():
1896 if field_value := item.get(field_name, None):
1897 kwargs[field_name] = field_value
1899 stock_item.move(
1900 location, notes, request.user, quantity=quantity, **kwargs
1901 )
1904class StockReturnSerializer(StockAdjustmentSerializer):
1905 """Serializer class for returning stock item(s) into stock."""
1907 class Meta:
1908 """Metaclass options."""
1910 fields = ['items', 'notes', 'location']
1912 items = StockAdjustmentItemSerializer(
1913 many=True, require_in_stock=False, require_non_zero=True
1914 )
1916 location = serializers.PrimaryKeyRelatedField(
1917 queryset=StockLocation.objects.filter(structural=False),
1918 many=False,
1919 required=True,
1920 allow_null=False,
1921 label=_('Location'),
1922 help_text=_('Destination stock location'),
1923 )
1925 merge = serializers.BooleanField(
1926 default=False,
1927 required=False,
1928 label=_('Merge into existing stock'),
1929 help_text=_('Merge returned items into existing stock items if possible'),
1930 )
1932 def save(self):
1933 """Return the provided items into stock."""
1934 request = self.context['request']
1935 data = self.validated_data
1936 items = data['items']
1937 merge = data.get('merge', False)
1938 notes = data.get('notes', '')
1939 location = data['location']
1941 with transaction.atomic():
1942 for item in items:
1943 stock_item = item['pk']
1944 quantity = item.get('quantity', None)
1946 # Optional fields
1947 kwargs = {'notes': notes}
1949 for field_name in StockItem.optional_transfer_fields():
1950 if field_value := item.get(field_name, None):
1951 kwargs[field_name] = field_value
1953 stock_item.return_to_stock(
1954 location,
1955 quantity=quantity,
1956 merge=merge,
1957 user=request.user,
1958 **kwargs,
1959 )
1962class StockItemSerialNumbersSerializer(InvenTree.serializers.InvenTreeModelSerializer):
1963 """Serializer for extra serial number information about a stock item."""
1965 class Meta:
1966 """Metaclass options."""
1968 model = StockItem
1969 fields = ['next', 'previous']
1971 next = StockItemSerializer(
1972 read_only=True, source='get_next_stock_item', label=_('Next Serial Number')
1973 )
1975 previous = StockItemSerializer(
1976 read_only=True,
1977 source='get_previous_stock_item',
1978 label=_('Previous Serial Number'),
1979 )