Coverage for src/backend/InvenTree/order/serializers.py: 53%
880 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 the Order API."""
3from decimal import Decimal
5from django.core.exceptions import ValidationError as DjangoValidationError
6from django.db import models, transaction
7from django.db.models import BooleanField, Case, ExpressionWrapper, F, Q, Value, When
8from django.db.models.functions import Coalesce, Greatest
9from django.utils.translation import gettext_lazy as _
11from rest_framework import serializers
12from rest_framework.serializers import ValidationError
13from sql_util.utils import SubqueryCount, SubquerySum
15import build.serializers
16import common.filters
17import company.models as company_models
18import order.models
19import part.filters as part_filters
20import stock.models
21import stock.serializers
22from common.settings import get_global_setting
23from company.serializers import (
24 AddressBriefSerializer,
25 CompanyBriefSerializer,
26 ContactSerializer,
27 SupplierPartSerializer,
28)
29from generic.states.fields import InvenTreeCustomStatusSerializerMixin
30from importer.registry import register_importer
31from InvenTree.helpers import extract_serial_numbers, hash_barcode, normalize, str2bool
32from InvenTree.mixins import DataImportExportSerializerMixin
33from InvenTree.serializers import (
34 CustomStatusSerializerMixin,
35 FilterableSerializerMixin,
36 InvenTreeCurrencySerializer,
37 InvenTreeDecimalField,
38 InvenTreeModelSerializer,
39 InvenTreeMoneySerializer,
40 InvenTreeTaggitSerializer,
41 NotesFieldMixin,
42 OptionalField,
43)
44from order.status_codes import (
45 PurchaseOrderStatusGroups,
46 ReturnOrderLineStatus,
47 ReturnOrderStatus,
48 SalesOrderStatusGroups,
49 TransferOrderStatusGroups,
50)
51from part.serializers import PartBriefSerializer
52from stock.status_codes import StockStatus
53from users.serializers import OwnerSerializer, UserSerializer
56class TotalPriceMixin(serializers.Serializer):
57 """Serializer mixin which provides total price fields."""
59 total_price = InvenTreeMoneySerializer(allow_null=True, read_only=True)
61 order_currency = InvenTreeCurrencySerializer(
62 allow_blank=True,
63 allow_null=True,
64 required=False,
65 label=_('Order Currency'),
66 help_text=_('Currency for this order (leave blank to use company default)'),
67 )
70class DuplicateOrderSerializer(serializers.Serializer):
71 """Serializer for specifying options when duplicating an order."""
73 class Meta:
74 """Metaclass options."""
76 fields = ['order_id', 'copy_lines', 'copy_extra_lines', 'copy_parameters']
78 order_id = serializers.IntegerField(
79 required=True, label=_('Order ID'), help_text=_('ID of the order to duplicate')
80 )
82 copy_lines = serializers.BooleanField(
83 required=False,
84 default=True,
85 label=_('Copy Lines'),
86 help_text=_('Copy line items from the original order'),
87 )
89 copy_extra_lines = serializers.BooleanField(
90 required=False,
91 default=True,
92 label=_('Copy Extra Lines'),
93 help_text=_('Copy extra line items from the original order'),
94 )
96 copy_parameters = serializers.BooleanField(
97 required=False,
98 default=True,
99 label=_('Copy Parameters'),
100 help_text=_('Copy order parameters from the original order'),
101 )
104class AbstractOrderSerializer(
105 CustomStatusSerializerMixin,
106 DataImportExportSerializerMixin,
107 InvenTreeTaggitSerializer,
108 FilterableSerializerMixin,
109 serializers.Serializer,
110):
111 """Abstract serializer class which provides fields common to all order types."""
113 export_exclude_fields = ['notes', 'duplicate']
115 import_exclude_fields = ['notes', 'duplicate']
117 # Number of line items in this order
118 line_items = serializers.IntegerField(
119 read_only=True, allow_null=True, label=_('Line Items')
120 )
122 # Number of completed line items (this is an annotated field)
123 completed_lines = serializers.IntegerField(
124 read_only=True, allow_null=True, label=_('Completed Lines')
125 )
127 # status field cannot be set directly
128 status = serializers.IntegerField(read_only=True, label=_('Order Status'))
130 # can be set directly, but must be valid for the current order status
131 status_custom_key = serializers.IntegerField(
132 label=_('Custom Status Key'),
133 help_text=_('Update order status to a custom value for this logical value'),
134 allow_null=True,
135 default=None,
136 )
138 # Reference string is *required*
139 reference = serializers.CharField(required=True)
141 # Detail for point-of-contact field
142 contact_detail = OptionalField(
143 serializer_class=ContactSerializer,
144 serializer_kwargs={
145 'source': 'contact',
146 'many': False,
147 'read_only': True,
148 'allow_null': True,
149 },
150 default_include=True,
151 prefetch_fields=['contact'],
152 )
154 # Detail for responsible field
155 responsible_detail = OptionalField(
156 serializer_class=OwnerSerializer,
157 serializer_kwargs={
158 'source': 'responsible',
159 'read_only': True,
160 'allow_null': True,
161 'many': False,
162 },
163 default_include=True,
164 prefetch_fields=['responsible'],
165 )
167 project_code_label = common.filters.enable_project_label_filter()
168 project_code_detail = common.filters.enable_project_code_filter()
170 # Detail for address field
171 address_detail = OptionalField(
172 serializer_class=AddressBriefSerializer,
173 serializer_kwargs={
174 'source': 'address',
175 'many': False,
176 'read_only': True,
177 'allow_null': True,
178 },
179 default_include=True,
180 prefetch_fields=['address'],
181 )
183 parameters = common.filters.enable_parameters_filter()
185 tags = common.filters.enable_tags_filter()
187 # Boolean field indicating if this order is overdue (Note: must be annotated)
188 overdue = serializers.BooleanField(read_only=True, allow_null=True)
190 barcode_hash = serializers.CharField(read_only=True)
192 creation_date = serializers.DateField(
193 required=False, allow_null=True, label=_('Creation Date')
194 )
196 created_by = UserSerializer(read_only=True)
198 duplicate = DuplicateOrderSerializer(
199 label=_('Duplicate Order'),
200 help_text=_('Specify options for duplicating this order'),
201 required=False,
202 write_only=True,
203 )
205 def validate_reference(self, reference):
206 """Custom validation for the reference field."""
207 self.Meta.model.validate_reference_field(reference)
208 return reference
210 def validate_status_custom_key(self, value):
211 """Validate the status_custom_key field.
213 Ensure the custom status key is valid for the logical order status.
214 """
215 if value is None: 215 ↛ 218line 215 didn't jump to line 218 because the condition on line 215 was always true
216 return value
218 from generic.states.custom import get_logical_value
220 if not isinstance(value, int):
221 raise ValidationError(_('Custom status key must be an integer'))
223 try:
224 custom_status = get_logical_value(
225 value, model=self.Meta.model._meta.model_name
226 )
227 except:
228 raise ValidationError(_('Invalid custom status key'))
230 if custom_status.logical_key is not self.instance.status:
231 raise ValidationError(_('Invalid custom status key for this order status'))
233 return value
235 @staticmethod
236 def annotate_queryset(queryset):
237 """Add extra information to the queryset."""
238 queryset = queryset.annotate(line_items=SubqueryCount('lines'))
239 queryset = queryset.select_related('created_by')
241 return queryset
243 @staticmethod
244 def order_fields(extra_fields):
245 """Construct a set of fields for this serializer."""
246 return [
247 'pk',
248 'created_by',
249 'creation_date',
250 'issue_date',
251 'start_date',
252 'target_date',
253 'description',
254 'line_items',
255 'completed_lines',
256 'link',
257 'project_code',
258 'reference',
259 'responsible',
260 'contact',
261 'address',
262 'status',
263 'status_text',
264 'status_custom_key',
265 'notes',
266 'barcode_hash',
267 'overdue',
268 'duplicate',
269 # Extra detail fields
270 'address_detail',
271 'contact_detail',
272 'project_code_detail',
273 'project_code_label',
274 'responsible_detail',
275 'parameters',
276 'tags',
277 *extra_fields,
278 ]
280 def clean_line_item(self, line):
281 """Clean a line item object (when duplicating)."""
282 line.pk = None
283 line.order = self
285 @transaction.atomic
286 def create(self, validated_data):
287 """Create a new order object.
289 Optionally, copy line items from an existing order.
290 """
291 duplicate = validated_data.pop('duplicate', None)
293 instance = super().create(validated_data)
295 if duplicate: 295 ↛ 296line 295 didn't jump to line 296 because the condition on line 295 was never true
296 order_id = duplicate.get('order_id', None)
297 copy_lines = duplicate.get('copy_lines', True)
298 copy_extra_lines = duplicate.get('copy_extra_lines', True)
299 copy_parameters = duplicate.get('copy_parameters', True)
301 try:
302 copy_from = instance.__class__.objects.get(pk=order_id)
303 except Exception:
304 # If the order ID is invalid, raise a validation error
305 raise ValidationError(_('Invalid order ID'))
307 if copy_lines:
308 for line in copy_from.lines.all():
309 instance.clean_line_item(line)
310 line.save()
312 if copy_extra_lines:
313 for line in copy_from.extra_lines.all():
314 line.pk = None
315 line.order = instance
316 line.save()
318 if copy_parameters:
319 instance.copy_parameters_from(copy_from)
321 return instance
324class AbstractLineItemSerializer(FilterableSerializerMixin, serializers.Serializer):
325 """Abstract serializer for LineItem object."""
327 @staticmethod
328 def line_fields(extra_fields):
329 """Construct a set of fields for this serializer."""
330 return [
331 'pk',
332 'line',
333 'link',
334 'notes',
335 'order',
336 'project_code',
337 'quantity',
338 'reference',
339 'target_date',
340 # Filterable detail fields
341 'order_detail',
342 'project_code_label',
343 'project_code_detail',
344 *extra_fields,
345 ]
347 target_date = serializers.DateField(
348 required=False, allow_null=True, label=_('Target Date')
349 )
351 project_code_label = common.filters.enable_project_label_filter()
353 project_code_detail = common.filters.enable_project_code_filter()
356class AbstractExtraLineSerializer(
357 DataImportExportSerializerMixin, FilterableSerializerMixin, serializers.Serializer
358):
359 """Abstract Serializer for a ExtraLine object."""
361 @staticmethod
362 def extra_line_fields(extra_fields):
363 """Construct a set of fields for this serializer."""
364 return [
365 'pk',
366 'line',
367 'description',
368 'link',
369 'notes',
370 'order',
371 'price',
372 'price_currency',
373 'project_code',
374 'quantity',
375 'reference',
376 'target_date',
377 # Filterable detail fields
378 'order_detail',
379 'project_code_label',
380 'project_code_detail',
381 *extra_fields,
382 ]
384 quantity = serializers.FloatField()
386 price = InvenTreeMoneySerializer(allow_null=True)
388 price_currency = InvenTreeCurrencySerializer()
390 project_code_label = common.filters.enable_project_label_filter()
392 project_code_detail = common.filters.enable_project_code_filter()
395class AbstractExtraLineMeta:
396 """Abstract Meta for ExtraLine."""
398 fields = [
399 'pk',
400 'description',
401 'quantity',
402 'reference',
403 'notes',
404 'context',
405 'order',
406 'order_detail',
407 'price',
408 'price_currency',
409 'link',
410 ]
413@register_importer()
414class PurchaseOrderSerializer(
415 NotesFieldMixin,
416 TotalPriceMixin,
417 InvenTreeCustomStatusSerializerMixin,
418 AbstractOrderSerializer,
419 InvenTreeModelSerializer,
420):
421 """Serializer for a PurchaseOrder object."""
423 class Meta:
424 """Metaclass options."""
426 model = order.models.PurchaseOrder
427 fields = AbstractOrderSerializer.order_fields([
428 'complete_date',
429 'supplier',
430 'supplier_detail',
431 'supplier_reference',
432 'supplier_name',
433 'total_price',
434 'order_currency',
435 'destination',
436 'updated_at',
437 ])
438 read_only_fields = [
439 'issue_date',
440 'complete_date',
441 'creation_date',
442 'updated_at',
443 ]
444 extra_kwargs = {
445 'supplier': {'required': True},
446 'order_currency': {'required': False},
447 }
449 def skip_create_fields(self):
450 """Skip these fields when instantiating a new object."""
451 fields = super().skip_create_fields()
453 return [*fields, 'duplicate']
455 @staticmethod
456 def annotate_queryset(queryset):
457 """Add extra information to the queryset.
459 - Number of lines in the PurchaseOrder
460 - Overdue status of the PurchaseOrder
461 """
462 queryset = AbstractOrderSerializer.annotate_queryset(queryset)
464 queryset = queryset.annotate(
465 completed_lines=SubqueryCount(
466 'lines', filter=Q(quantity__lte=F('received'))
467 )
468 )
470 queryset = queryset.annotate(
471 overdue=Case(
472 When(
473 order.models.PurchaseOrder.overdue_filter(),
474 then=Value(True, output_field=BooleanField()),
475 ),
476 default=Value(False, output_field=BooleanField()),
477 )
478 )
480 queryset = queryset.prefetch_related('created_by')
482 return queryset
484 supplier_name = serializers.CharField(
485 source='supplier.name', read_only=True, label=_('Supplier Name')
486 )
488 supplier_detail = OptionalField(
489 serializer_class=CompanyBriefSerializer,
490 serializer_kwargs={
491 'source': 'supplier',
492 'many': False,
493 'read_only': True,
494 'allow_null': True,
495 },
496 prefetch_fields=['supplier'],
497 )
500class OrderAdjustSerializer(serializers.Serializer):
501 """Generic serializer class for adjusting the status of an order."""
503 class Meta:
504 """Metaclass options.
506 By default, there are no fields required for this serializer type.
507 """
509 fields = []
511 @property
512 def order(self):
513 """Return the order object associated with this serializer.
515 Note: It is passed in via the serializer context data.
516 """
517 return self.context['order']
520class PurchaseOrderHoldSerializer(OrderAdjustSerializer):
521 """Serializer for placing a PurchaseOrder on hold."""
523 def save(self):
524 """Save the serializer to 'hold' the order."""
525 self.order.hold_order()
528class PurchaseOrderCancelSerializer(OrderAdjustSerializer):
529 """Serializer for cancelling a PurchaseOrder."""
531 def save(self):
532 """Save the serializer to 'cancel' the order."""
533 if not self.order.can_cancel: 533 ↛ anywhereline 533 didn't jump anywhere: it always raised an exception.
534 raise ValidationError(_('Order cannot be cancelled'))
536 self.order.cancel_order()
539class PurchaseOrderCompleteSerializer(OrderAdjustSerializer):
540 """Serializer for completing a purchase order."""
542 class Meta:
543 """Metaclass options."""
545 fields = ['accept_incomplete']
547 accept_incomplete = serializers.BooleanField(
548 label=_('Accept Incomplete'),
549 help_text=_('Allow order to be closed with incomplete line items'),
550 required=False,
551 default=False,
552 )
554 def validate_accept_incomplete(self, value):
555 """Check if the 'accept_incomplete' field is required."""
556 order = self.context['order']
558 if not value and not order.is_complete:
559 raise ValidationError(_('Order has incomplete line items'))
561 return value
563 def get_context_data(self):
564 """Custom context information for this serializer."""
565 order = self.context['order']
567 return {'is_complete': order.is_complete}
569 def save(self):
570 """Save the serializer to 'complete' the order."""
571 self.order.complete_order()
574class PurchaseOrderIssueSerializer(OrderAdjustSerializer):
575 """Serializer for issuing (sending) a purchase order."""
577 def save(self):
578 """Save the serializer to 'place' the order."""
579 self.order.place_order()
582@register_importer()
583class PurchaseOrderLineItemSerializer(
584 DataImportExportSerializerMixin,
585 AbstractLineItemSerializer,
586 InvenTreeModelSerializer,
587):
588 """Serializer class for the PurchaseOrderLineItem model."""
590 class Meta:
591 """Metaclass options."""
593 model = order.models.PurchaseOrderLineItem
594 fields = AbstractLineItemSerializer.line_fields([
595 'part',
596 'build_order',
597 'overdue',
598 'received',
599 'purchase_price',
600 'purchase_price_currency',
601 'auto_pricing',
602 'destination',
603 'total_price',
604 'merge_items',
605 'sku',
606 'mpn',
607 'ipn',
608 'internal_part',
609 'internal_part_name',
610 # Filterable detail fields
611 'build_order_detail',
612 'destination_detail',
613 'part_detail',
614 'supplier_part_detail',
615 ])
617 def skip_create_fields(self):
618 """Return a list of fields to skip when creating a new object."""
619 return ['auto_pricing', 'merge_items', *super().skip_create_fields()]
621 @staticmethod
622 def annotate_queryset(queryset):
623 """Add some extra annotations to this queryset.
625 - "total_price" = purchase_price * quantity
626 - "overdue" status (boolean field)
627 """
628 queryset = queryset.prefetch_related(
629 'order',
630 'order__responsible',
631 'order__stock_items',
632 'part',
633 'part__part',
634 'part__part__pricing_data',
635 'part__part__default_location',
636 'part__supplier',
637 'part__manufacturer_part',
638 'part__manufacturer_part__manufacturer',
639 )
641 queryset = queryset.annotate(
642 total_price=ExpressionWrapper(
643 F('purchase_price') * F('quantity'), output_field=models.DecimalField()
644 )
645 )
647 queryset = queryset.annotate(
648 overdue=Case(
649 When(
650 order.models.PurchaseOrderLineItem.get_overdue_filter(),
651 then=Value(True, output_field=BooleanField()),
652 ),
653 default=Value(False, output_field=BooleanField()),
654 )
655 )
657 return queryset
659 part = serializers.PrimaryKeyRelatedField(
660 queryset=company_models.SupplierPart.objects.all(),
661 many=False,
662 required=True,
663 allow_null=True,
664 label=_('Supplier Part'),
665 )
667 quantity = serializers.FloatField(min_value=0, required=True)
669 def validate_quantity(self, quantity):
670 """Validation for the 'quantity' field."""
671 if quantity <= 0:
672 raise ValidationError(_('Quantity must be greater than zero'))
674 return quantity
676 def validate_purchase_order(self, purchase_order):
677 """Validation for the 'purchase_order' field."""
678 if purchase_order.status not in PurchaseOrderStatusGroups.OPEN:
679 raise ValidationError(_('Order is not open'))
681 return purchase_order
683 received = serializers.FloatField(default=0, read_only=True)
685 overdue = serializers.BooleanField(read_only=True, allow_null=True)
687 total_price = serializers.FloatField(read_only=True)
689 part_detail = OptionalField(
690 serializer_class=PartBriefSerializer,
691 serializer_kwargs={
692 'source': 'get_base_part',
693 'many': False,
694 'read_only': True,
695 'allow_null': True,
696 },
697 default_include=False,
698 filter_name='part_detail',
699 )
701 supplier_part_detail = OptionalField(
702 serializer_class=SupplierPartSerializer,
703 serializer_kwargs={
704 'source': 'part',
705 'brief': True,
706 'many': False,
707 'read_only': True,
708 'allow_null': True,
709 },
710 default_include=False,
711 filter_name='part_detail',
712 )
714 purchase_price = InvenTreeMoneySerializer(allow_null=True)
716 auto_pricing = serializers.BooleanField(
717 label=_('Auto Pricing'),
718 help_text=_(
719 'Automatically calculate purchase price based on supplier part data'
720 ),
721 default=False,
722 )
724 destination_detail = OptionalField(
725 serializer_class=stock.serializers.LocationBriefSerializer,
726 serializer_kwargs={
727 'source': 'get_destination',
728 'read_only': True,
729 'allow_null': True,
730 },
731 default_include=True,
732 prefetch_fields=['destination', 'order__destination'],
733 )
735 purchase_price_currency = InvenTreeCurrencySerializer(
736 help_text=_('Purchase price currency')
737 )
739 order_detail = OptionalField(
740 serializer_class=PurchaseOrderSerializer,
741 serializer_kwargs={
742 'source': 'order',
743 'read_only': True,
744 'allow_null': True,
745 'many': False,
746 },
747 default_include=True,
748 )
750 build_order_detail = OptionalField(
751 serializer_class=build.serializers.BuildSerializer,
752 serializer_kwargs={
753 'source': 'build_order',
754 'read_only': True,
755 'allow_null': True,
756 'many': False,
757 },
758 default_include=True,
759 prefetch_fields=[
760 'build_order__responsible',
761 'build_order__issued_by',
762 'build_order__part',
763 ],
764 )
766 merge_items = serializers.BooleanField(
767 label=_('Merge Items'),
768 help_text=_(
769 'Merge items with the same part, destination and target date into one line item'
770 ),
771 default=True,
772 write_only=True,
773 )
775 sku = serializers.CharField(
776 source='part.SKU', read_only=True, allow_null=True, label=_('SKU')
777 )
779 mpn = serializers.CharField(
780 source='part.manufacturer_part.MPN',
781 read_only=True,
782 allow_null=True,
783 label=_('MPN'),
784 )
786 ipn = serializers.CharField(
787 source='part.part.IPN',
788 read_only=True,
789 allow_null=True,
790 label=_('Internal Part Number'),
791 )
793 internal_part = serializers.PrimaryKeyRelatedField(
794 source='part.part', read_only=True, many=False, label=_('Internal Part')
795 )
797 internal_part_name = serializers.CharField(
798 source='part.part.name', read_only=True, label=_('Internal Part Name')
799 )
801 def validate(self, data):
802 """Custom validation for the serializer.
804 - Ensure the supplier_part field is supplied
805 - Ensure the purchase_order field is supplied
806 - Ensure that the supplier_part and supplier references match
807 """
808 data = super().validate(data)
810 supplier_part = data.get('part', None)
811 purchase_order = data.get('order', None)
813 if not supplier_part:
814 raise ValidationError({'part': _('Supplier part must be specified')})
816 if not purchase_order:
817 raise ValidationError({'order': _('Purchase order must be specified')})
819 # Check that the supplier part and purchase order match
820 if (
821 supplier_part is not None
822 and supplier_part.supplier != purchase_order.supplier
823 ):
824 raise ValidationError({
825 'part': _('Supplier must match purchase order'),
826 'order': _('Purchase order must match supplier'),
827 })
829 return data
832@register_importer()
833class PurchaseOrderExtraLineSerializer(
834 AbstractExtraLineSerializer, InvenTreeModelSerializer
835):
836 """Serializer for a PurchaseOrderExtraLine object."""
838 class Meta(AbstractExtraLineMeta):
839 """Metaclass options."""
841 model = order.models.PurchaseOrderExtraLine
842 fields = AbstractExtraLineSerializer.extra_line_fields([])
844 order_detail = OptionalField(
845 serializer_class=PurchaseOrderSerializer,
846 serializer_kwargs={
847 'source': 'order',
848 'many': False,
849 'read_only': True,
850 'allow_null': True,
851 },
852 )
855class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
856 """A serializer for receiving a single purchase order line item against a purchase order."""
858 class Meta:
859 """Metaclass options."""
861 fields = [
862 'barcode',
863 'line_item',
864 'location',
865 'quantity',
866 'status',
867 'batch_code',
868 'expiry_date',
869 'serial_numbers',
870 'packaging',
871 'note',
872 ]
874 line_item = serializers.PrimaryKeyRelatedField(
875 queryset=order.models.PurchaseOrderLineItem.objects.all(),
876 allow_null=False,
877 required=True,
878 label=_('Line Item'),
879 )
881 location = serializers.PrimaryKeyRelatedField(
882 queryset=stock.models.StockLocation.objects.all(),
883 many=False,
884 allow_null=True,
885 required=False,
886 label=_('Location'),
887 help_text=_('Select destination location for received items'),
888 )
890 quantity = serializers.DecimalField(
891 max_digits=15, decimal_places=5, min_value=Decimal(0), required=True
892 )
894 def validate_quantity(self, quantity):
895 """Validation for the 'quantity' field."""
896 if quantity <= 0: 896 ↛ 897line 896 didn't jump to line 897 because the condition on line 896 was never true
897 raise ValidationError(_('Quantity must be greater than zero'))
899 return quantity
901 batch_code = serializers.CharField(
902 label=_('Batch Code'),
903 help_text=_('Enter batch code for incoming stock items'),
904 required=False,
905 default='',
906 allow_blank=True,
907 )
909 expiry_date = serializers.DateField(
910 label=_('Expiry Date'),
911 help_text=_('Enter expiry date for incoming stock items'),
912 required=False,
913 allow_null=True,
914 default=None,
915 )
917 serial_numbers = serializers.CharField(
918 label=_('Serial Numbers'),
919 help_text=_('Enter serial numbers for incoming stock items'),
920 required=False,
921 default='',
922 allow_blank=True,
923 )
925 status = stock.serializers.StockStatusCustomSerializer(default=StockStatus.OK.value)
927 packaging = serializers.CharField(
928 label=_('Packaging'),
929 help_text=_('Override packaging information for incoming stock items'),
930 required=False,
931 default='',
932 allow_blank=True,
933 )
935 note = serializers.CharField(
936 label=_('Note'),
937 help_text=_('Additional note for incoming stock items'),
938 required=False,
939 default='',
940 allow_blank=True,
941 )
943 barcode = serializers.CharField(
944 label=_('Barcode'),
945 help_text=_('Scanned barcode'),
946 default='',
947 required=False,
948 allow_null=True,
949 allow_blank=True,
950 )
952 def validate_barcode(self, barcode):
953 """Cannot check in a LineItem with a barcode that is already assigned."""
954 # Ignore empty barcode values
955 if not barcode or barcode.strip() == '': 955 ↛ 958line 955 didn't jump to line 958 because the condition on line 955 was always true
956 return None
958 barcode_hash = hash_barcode(barcode)
960 if stock.models.StockItem.lookup_barcode(barcode_hash) is not None:
961 raise ValidationError(_('Barcode is already in use'))
963 return barcode
965 def validate(self, data):
966 """Custom validation for the serializer.
968 - Integer quantity must be provided for serialized stock
969 - Validate serial numbers (if provided)
970 """
971 data = super().validate(data)
973 line_item = data['line_item']
974 quantity = data['quantity']
975 serial_numbers = data.get('serial_numbers', '').strip()
977 # If serial numbers are provided
978 if serial_numbers:
979 supplier_part = line_item.part
980 base_part = supplier_part.part
981 base_quantity = supplier_part.base_quantity(quantity)
983 try:
984 # Pass the serial numbers through to the parent serializer once validated
985 data['serials'] = extract_serial_numbers(
986 serial_numbers,
987 base_quantity,
988 base_part.get_latest_serial_number(),
989 part=base_part,
990 )
991 except DjangoValidationError as e:
992 raise ValidationError({'serial_numbers': e.messages})
994 invalid_serials = []
996 # Check the serial numbers are valid
997 for serial in data['serials']:
998 try:
999 base_part.validate_serial_number(serial, raise_error=True)
1000 except (ValidationError, DjangoValidationError):
1001 invalid_serials.append(serial)
1003 if len(invalid_serials) > 0:
1004 msg = _('The following serial numbers already exist or are invalid')
1005 msg += ': ' + ', '.join(invalid_serials)
1006 raise ValidationError({'serial_numbers': msg})
1008 return data
1011class PurchaseOrderReceiveSerializer(serializers.Serializer):
1012 """Serializer for receiving items against a PurchaseOrder."""
1014 class Meta:
1015 """Metaclass options."""
1017 fields = ['items', 'location']
1019 items = PurchaseOrderLineItemReceiveSerializer(many=True)
1021 location = serializers.PrimaryKeyRelatedField(
1022 queryset=stock.models.StockLocation.objects.all(),
1023 many=False,
1024 required=False,
1025 allow_null=True,
1026 label=_('Location'),
1027 help_text=_('Select destination location for received items'),
1028 )
1030 def validate(self, data):
1031 """Custom validation for the serializer.
1033 - Ensure line items are provided
1034 - Check that a location is specified
1035 """
1036 super().validate(data)
1038 order = self.context['order']
1039 items = data.get('items', [])
1041 location = data.get('location', order.destination)
1043 if len(items) == 0:
1044 raise ValidationError(_('Line items must be provided'))
1046 # Ensure barcodes are unique
1047 unique_barcodes = set()
1049 # Check if the location is not specified for any particular item
1050 for item in items:
1051 line = item['line_item']
1053 if not item.get('location', None):
1054 # If a global location is specified, use that
1055 item['location'] = location
1057 if not item['location']:
1058 # The line item specifies a location?
1059 item['location'] = line.get_destination()
1061 if not item['location']:
1062 raise ValidationError({
1063 'location': _('Destination location must be specified')
1064 })
1066 barcode = item.get('barcode', '')
1068 if barcode:
1069 if barcode in unique_barcodes:
1070 raise ValidationError(_('Supplied barcode values must be unique'))
1071 else:
1072 unique_barcodes.add(barcode)
1074 return data
1076 def save(self) -> list[stock.models.StockItem]:
1077 """Perform the actual database transaction to receive purchase order items."""
1078 data = self.validated_data
1080 request = self.context.get('request')
1081 order = self.context['order']
1083 items = data['items']
1085 # Location can be provided, or default to the order destination
1086 location = data.get('location', order.destination)
1088 try:
1089 items = order.receive_line_items(
1090 location, items, request.user if request else None
1091 )
1092 except (ValidationError, DjangoValidationError) as exc:
1093 # Catch model errors and re-throw as DRF errors
1094 raise ValidationError(detail=serializers.as_serializer_error(exc))
1096 # Returns a list of the created items
1097 return items
1100@register_importer()
1101class SalesOrderSerializer(
1102 NotesFieldMixin,
1103 TotalPriceMixin,
1104 InvenTreeCustomStatusSerializerMixin,
1105 AbstractOrderSerializer,
1106 InvenTreeTaggitSerializer,
1107 InvenTreeModelSerializer,
1108):
1109 """Serializer for the SalesOrder model class."""
1111 class Meta:
1112 """Metaclass options."""
1114 model = order.models.SalesOrder
1115 fields = AbstractOrderSerializer.order_fields([
1116 'customer',
1117 'customer_detail',
1118 'customer_reference',
1119 'shipment_date',
1120 'total_price',
1121 'order_currency',
1122 'shipments_count',
1123 'completed_shipments_count',
1124 'allocated_lines',
1125 'updated_at',
1126 'tags',
1127 ])
1128 read_only_fields = ['status', 'creation_date', 'shipment_date', 'updated_at']
1129 extra_kwargs = {'order_currency': {'required': False}}
1131 def skip_create_fields(self):
1132 """Skip these fields when instantiating a new object."""
1133 fields = super().skip_create_fields()
1135 return [*fields, 'duplicate']
1137 @staticmethod
1138 def annotate_queryset(queryset):
1139 """Add extra information to the queryset.
1141 - Number of line items in the SalesOrder
1142 - Number of fully allocated line items
1143 - Number of completed line items in the SalesOrder
1144 - Overdue status of the SalesOrder
1145 """
1146 queryset = AbstractOrderSerializer.annotate_queryset(queryset)
1148 queryset = queryset.annotate(
1149 completed_lines=SubqueryCount('lines', filter=Q(quantity__lte=F('shipped')))
1150 )
1152 queryset = queryset.annotate(
1153 allocated_lines=SubqueryCount(
1154 'lines',
1155 filter=Q(part__virtual=True)
1156 | Q(shipped__gte=F('quantity'))
1157 | Q(
1158 quantity__lte=Coalesce(
1159 SubquerySum('allocations__quantity'), Decimal(0)
1160 )
1161 ),
1162 )
1163 )
1165 queryset = queryset.annotate(
1166 overdue=Case(
1167 When(
1168 order.models.SalesOrder.overdue_filter(),
1169 then=Value(True, output_field=BooleanField()),
1170 ),
1171 default=Value(False, output_field=BooleanField()),
1172 )
1173 )
1175 # Annotate shipment details
1176 queryset = queryset.annotate(
1177 shipments_count=SubqueryCount('shipments'),
1178 completed_shipments_count=SubqueryCount(
1179 'shipments', filter=Q(shipment_date__isnull=False)
1180 ),
1181 )
1183 return queryset
1185 customer_detail = OptionalField(
1186 serializer_class=CompanyBriefSerializer,
1187 serializer_kwargs={
1188 'source': 'customer',
1189 'many': False,
1190 'read_only': True,
1191 'allow_null': True,
1192 },
1193 prefetch_fields=['customer'],
1194 )
1196 shipments_count = serializers.IntegerField(
1197 read_only=True, allow_null=True, label=_('Shipments')
1198 )
1200 completed_shipments_count = serializers.IntegerField(
1201 read_only=True, allow_null=True, label=_('Completed Shipments')
1202 )
1204 allocated_lines = serializers.IntegerField(
1205 read_only=True, allow_null=True, label=_('Allocated Lines')
1206 )
1208 tags = common.filters.enable_tags_filter()
1211class SalesOrderIssueSerializer(OrderAdjustSerializer):
1212 """Serializer for issuing a SalesOrder."""
1214 def save(self):
1215 """Save the serializer to 'issue' the order."""
1216 self.order.issue_order()
1219@register_importer()
1220class SalesOrderLineItemSerializer(
1221 DataImportExportSerializerMixin,
1222 AbstractLineItemSerializer,
1223 InvenTreeModelSerializer,
1224):
1225 """Serializer for a SalesOrderLineItem object."""
1227 class Meta:
1228 """Metaclass options."""
1230 model = order.models.SalesOrderLineItem
1231 fields = AbstractLineItemSerializer.line_fields([
1232 'allocated',
1233 'customer_detail',
1234 'overdue',
1235 'part',
1236 'part_detail',
1237 'sale_price',
1238 'sale_price_currency',
1239 'shipped',
1240 # Annotated fields for part stocking information
1241 'available_stock',
1242 'available_variant_stock',
1243 'building',
1244 'on_order',
1245 # Filterable detail fields
1246 ])
1248 @staticmethod
1249 def annotate_queryset(queryset):
1250 """Add some extra annotations to this queryset.
1252 - "overdue" status (boolean field)
1253 - "available_quantity"
1254 - "building"
1255 - "on_order"
1256 """
1257 queryset = queryset.annotate(
1258 overdue=Case(
1259 When(
1260 Q(order__status__in=SalesOrderStatusGroups.OPEN)
1261 & order.models.SalesOrderLineItem.get_overdue_filter(),
1262 then=Value(True, output_field=BooleanField()),
1263 ),
1264 default=Value(False, output_field=BooleanField()),
1265 )
1266 )
1268 # Annotate each line with the available stock quantity
1269 # To do this, we need to look at the total stock and any allocations
1270 queryset = queryset.alias(
1271 total_stock=part_filters.annotate_total_stock(reference='part__'),
1272 allocated_to_sales_orders=part_filters.annotate_sales_order_allocations(
1273 reference='part__'
1274 ),
1275 allocated_to_build_orders=part_filters.annotate_build_order_allocations(
1276 reference='part__'
1277 ),
1278 )
1280 queryset = queryset.annotate(
1281 available_stock=Greatest(
1282 ExpressionWrapper(
1283 F('total_stock')
1284 - F('allocated_to_sales_orders')
1285 - F('allocated_to_build_orders'),
1286 output_field=models.DecimalField(),
1287 ),
1288 0,
1289 output_field=models.DecimalField(),
1290 )
1291 )
1293 # Filter for "variant" stock: Variant stock items must be salable and active
1294 variant_stock_query = part_filters.variant_stock_query(
1295 reference='part__'
1296 ).filter(part__salable=True, part__active=True)
1298 # Also add in available "variant" stock
1299 queryset = queryset.alias(
1300 variant_stock_total=part_filters.annotate_variant_quantity(
1301 variant_stock_query, reference='quantity'
1302 ),
1303 variant_bo_allocations=part_filters.annotate_variant_quantity(
1304 variant_stock_query, reference='sales_order_allocations__quantity'
1305 ),
1306 variant_so_allocations=part_filters.annotate_variant_quantity(
1307 variant_stock_query, reference='allocations__quantity'
1308 ),
1309 )
1311 queryset = queryset.annotate(
1312 available_variant_stock=Greatest(
1313 ExpressionWrapper(
1314 F('variant_stock_total')
1315 - F('variant_bo_allocations')
1316 - F('variant_so_allocations'),
1317 output_field=models.DecimalField(),
1318 ),
1319 0,
1320 output_field=models.DecimalField(),
1321 )
1322 )
1324 # Add information about the quantity of parts currently on order
1325 queryset = queryset.annotate(
1326 on_order=part_filters.annotate_on_order_quantity(reference='part__')
1327 )
1329 # Add information about the quantity of parts currently in production
1330 queryset = queryset.annotate(
1331 building=part_filters.annotate_in_production_quantity(reference='part__')
1332 )
1334 # Annotate total 'allocated' stock quantity
1335 queryset = queryset.annotate(
1336 allocated=Coalesce(
1337 SubquerySum('allocations__quantity'),
1338 Decimal(0),
1339 output_field=models.DecimalField(),
1340 )
1341 )
1343 return queryset
1345 order_detail = OptionalField(
1346 serializer_class=SalesOrderSerializer,
1347 serializer_kwargs={
1348 'source': 'order',
1349 'many': False,
1350 'read_only': True,
1351 'allow_null': True,
1352 },
1353 prefetch_fields=[
1354 'order__created_by',
1355 'order__responsible',
1356 'order__address',
1357 'order__project_code',
1358 'order__contact',
1359 ],
1360 )
1362 part_detail = OptionalField(
1363 serializer_class=PartBriefSerializer,
1364 serializer_kwargs={
1365 'source': 'part',
1366 'many': False,
1367 'read_only': True,
1368 'allow_null': True,
1369 },
1370 prefetch_fields=['part__pricing_data'],
1371 )
1373 customer_detail = OptionalField(
1374 serializer_class=CompanyBriefSerializer,
1375 serializer_kwargs={
1376 'source': 'order.customer',
1377 'many': False,
1378 'read_only': True,
1379 'allow_null': True,
1380 },
1381 prefetch_fields=['order__customer'],
1382 )
1384 # Annotated fields
1385 overdue = serializers.BooleanField(read_only=True, allow_null=True)
1386 available_stock = serializers.FloatField(read_only=True)
1387 available_variant_stock = serializers.FloatField(read_only=True)
1388 on_order = serializers.FloatField(label=_('On Order'), read_only=True)
1389 building = serializers.FloatField(label=_('In Production'), read_only=True)
1391 quantity = InvenTreeDecimalField()
1393 allocated = serializers.FloatField(read_only=True)
1395 shipped = InvenTreeDecimalField(read_only=True)
1397 sale_price = InvenTreeMoneySerializer(allow_null=True)
1399 sale_price_currency = InvenTreeCurrencySerializer(
1400 help_text=_('Sale price currency')
1401 )
1404@register_importer()
1405class SalesOrderShipmentSerializer(
1406 DataImportExportSerializerMixin,
1407 FilterableSerializerMixin,
1408 InvenTreeTaggitSerializer,
1409 NotesFieldMixin,
1410 InvenTreeModelSerializer,
1411):
1412 """Serializer for the SalesOrderShipment class."""
1414 class Meta:
1415 """Metaclass options."""
1417 model = order.models.SalesOrderShipment
1418 fields = [
1419 'pk',
1420 'order',
1421 'allocated_items',
1422 'shipment_date',
1423 'shipment_address',
1424 'delivery_date',
1425 'checked_by',
1426 'reference',
1427 'tracking_number',
1428 'invoice_number',
1429 'barcode_hash',
1430 'link',
1431 'notes',
1432 # Extra detail fields
1433 'parameters',
1434 'checked_by_detail',
1435 'customer_detail',
1436 'order_detail',
1437 'shipment_address_detail',
1438 'tags',
1439 ]
1441 @staticmethod
1442 def annotate_queryset(queryset):
1443 """Annotate the queryset with extra information."""
1444 queryset = queryset.annotate(allocated_items=SubqueryCount('allocations'))
1446 return queryset
1448 allocated_items = serializers.IntegerField(
1449 read_only=True, allow_null=True, label=_('Allocated Items')
1450 )
1452 checked_by_detail = OptionalField(
1453 serializer_class=UserSerializer,
1454 serializer_kwargs={
1455 'source': 'checked_by',
1456 'many': False,
1457 'read_only': True,
1458 'allow_null': True,
1459 },
1460 default_include=True,
1461 prefetch_fields=['checked_by'],
1462 )
1464 order_detail = OptionalField(
1465 serializer_class=SalesOrderSerializer,
1466 serializer_kwargs={
1467 'source': 'order',
1468 'many': False,
1469 'read_only': True,
1470 'allow_null': True,
1471 },
1472 default_include=True,
1473 prefetch_fields=[
1474 'order',
1475 'order__customer',
1476 'order__created_by',
1477 'order__responsible',
1478 'order__project_code',
1479 ],
1480 )
1482 customer_detail = OptionalField(
1483 serializer_class=CompanyBriefSerializer,
1484 serializer_kwargs={
1485 'source': 'order.customer',
1486 'many': False,
1487 'read_only': True,
1488 'allow_null': True,
1489 },
1490 default_include=False,
1491 prefetch_fields=['order__customer'],
1492 )
1494 shipment_address_detail = OptionalField(
1495 serializer_class=AddressBriefSerializer,
1496 serializer_kwargs={
1497 'source': 'shipment_address',
1498 'many': False,
1499 'read_only': True,
1500 'allow_null': True,
1501 },
1502 default_include=True,
1503 prefetch_fields=['shipment_address'],
1504 )
1506 parameters = common.filters.enable_parameters_filter()
1508 tags = common.filters.enable_tags_filter()
1511class SalesOrderAllocationSerializer(
1512 FilterableSerializerMixin, InvenTreeModelSerializer
1513):
1514 """Serializer for the SalesOrderAllocation model.
1516 This includes some fields from the related model objects.
1517 """
1519 class Meta:
1520 """Metaclass options."""
1522 model = order.models.SalesOrderAllocation
1523 fields = [
1524 'pk',
1525 'item',
1526 'quantity',
1527 'shipment',
1528 # Annotated read-only fields
1529 'line',
1530 'part',
1531 'order',
1532 'serial',
1533 'location',
1534 # Extra detail fields
1535 'item_detail',
1536 'part_detail',
1537 'order_detail',
1538 'customer_detail',
1539 'location_detail',
1540 'shipment_detail',
1541 ]
1542 read_only_fields = ['line', '']
1544 part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True)
1545 order = serializers.PrimaryKeyRelatedField(
1546 source='line.order', many=False, read_only=True
1547 )
1548 serial = serializers.CharField(source='get_serial', read_only=True, allow_null=True)
1549 quantity = serializers.FloatField(read_only=False)
1550 location = serializers.PrimaryKeyRelatedField(
1551 source='item.location', many=False, read_only=True
1552 )
1554 # Extra detail fields
1555 order_detail = OptionalField(
1556 serializer_class=SalesOrderSerializer,
1557 serializer_kwargs={
1558 'source': 'line.order',
1559 'many': False,
1560 'read_only': True,
1561 'allow_null': True,
1562 },
1563 )
1565 part_detail = OptionalField(
1566 serializer_class=PartBriefSerializer,
1567 serializer_kwargs={
1568 'source': 'item.part',
1569 'many': False,
1570 'read_only': True,
1571 'allow_null': True,
1572 },
1573 default_include=True,
1574 )
1576 item_detail = OptionalField(
1577 serializer_class=stock.serializers.StockItemSerializer,
1578 serializer_kwargs={
1579 'source': 'item',
1580 'many': False,
1581 'read_only': True,
1582 'allow_null': True,
1583 'part_detail': False,
1584 'location_detail': False,
1585 'supplier_part_detail': False,
1586 },
1587 default_include=True,
1588 )
1589 location_detail = OptionalField(
1590 serializer_class=stock.serializers.LocationBriefSerializer,
1591 serializer_kwargs={
1592 'source': 'item.location',
1593 'many': False,
1594 'read_only': True,
1595 'allow_null': True,
1596 },
1597 )
1599 customer_detail = OptionalField(
1600 serializer_class=CompanyBriefSerializer,
1601 serializer_kwargs={
1602 'source': 'line.order.customer',
1603 'many': False,
1604 'read_only': True,
1605 'allow_null': True,
1606 },
1607 )
1609 shipment_detail = OptionalField(
1610 serializer_class=SalesOrderShipmentSerializer,
1611 serializer_kwargs={
1612 'source': 'shipment',
1613 'order_detail': False,
1614 'many': False,
1615 'read_only': True,
1616 'allow_null': True,
1617 },
1618 default_include=True,
1619 prefetch_fields=['shipment'],
1620 )
1623class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
1624 """Serializer for completing (shipping) a SalesOrderShipment."""
1626 class Meta:
1627 """Metaclass options."""
1629 model = order.models.SalesOrderShipment
1631 fields = [
1632 'shipment_date',
1633 'delivery_date',
1634 'tracking_number',
1635 'invoice_number',
1636 'link',
1637 ]
1639 def validate(self, data):
1640 """Custom validation for the serializer.
1642 - Ensure the shipment reference is provided
1643 """
1644 data = super().validate(data)
1646 shipment = self.context.get('shipment', None)
1648 if not shipment:
1649 raise ValidationError(_('No shipment details provided'))
1651 shipment.check_can_complete(raise_error=True)
1653 return data
1656class SalesOrderShipmentAllocationItemSerializer(serializers.Serializer):
1657 """A serializer for allocating a single stock-item against a SalesOrder shipment."""
1659 class Meta:
1660 """Metaclass options."""
1662 fields = ['line_item', 'stock_item', 'quantity']
1664 line_item = serializers.PrimaryKeyRelatedField(
1665 queryset=order.models.SalesOrderLineItem.objects.all(),
1666 many=False,
1667 allow_null=False,
1668 required=True,
1669 label=_('Stock Item'),
1670 )
1672 def validate_line_item(self, line_item):
1673 """Custom validation for the 'line_item' field.
1675 - Ensure the line_item is associated with the particular SalesOrder
1676 """
1677 order = self.context['order']
1679 # Ensure that the line item points to the correct order
1680 if line_item.order != order:
1681 raise ValidationError(_('Line item is not associated with this order'))
1683 return line_item
1685 stock_item = serializers.PrimaryKeyRelatedField(
1686 queryset=stock.models.StockItem.objects.all(),
1687 many=False,
1688 allow_null=False,
1689 required=True,
1690 label=_('Stock Item'),
1691 )
1693 quantity = serializers.DecimalField(
1694 max_digits=15, decimal_places=5, min_value=Decimal(0), required=True
1695 )
1697 def validate_quantity(self, quantity):
1698 """Custom validation for the 'quantity' field."""
1699 if quantity <= 0: 1699 ↛ 1700line 1699 didn't jump to line 1700 because the condition on line 1699 was never true
1700 raise ValidationError(_('Quantity must be positive'))
1702 return quantity
1704 def validate(self, data):
1705 """Custom validation for the serializer.
1707 - Ensure that the quantity is 1 for serialized stock
1708 - Quantity cannot exceed the available amount
1709 """
1710 data = super().validate(data)
1712 stock_item = data['stock_item']
1713 quantity = data['quantity']
1715 if get_global_setting('SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS'):
1716 if (
1717 stock_item.hasRequiredTests()
1718 and not stock_item.passedAllRequiredTests()
1719 ):
1720 raise ValidationError({
1721 'stock_item': _('Stock item has not passed all required tests')
1722 })
1724 if stock_item.serialized and quantity != 1:
1725 raise ValidationError({
1726 'quantity': _('Quantity must be 1 for serialized stock item')
1727 })
1729 q = normalize(stock_item.unallocated_quantity())
1731 if quantity > q:
1732 raise ValidationError({'quantity': _(f'Available quantity ({q}) exceeded')})
1734 return data
1737class SalesOrderCompleteSerializer(OrderAdjustSerializer):
1738 """DRF serializer for manually marking a sales order as complete."""
1740 class Meta:
1741 """Serializer metaclass options."""
1743 fields = ['accept_incomplete']
1745 accept_incomplete = serializers.BooleanField(
1746 label=_('Accept Incomplete'),
1747 help_text=_('Allow order to be closed with incomplete line items'),
1748 required=False,
1749 default=False,
1750 )
1752 def validate_accept_incomplete(self, value):
1753 """Check if the 'accept_incomplete' field is required."""
1754 order = self.context['order']
1756 if not value and not order.is_completed():
1757 raise ValidationError(_('Order has incomplete line items'))
1759 return value
1761 def get_context_data(self):
1762 """Custom context data for this serializer."""
1763 order = self.context['order']
1765 return {
1766 'is_complete': order.is_completed(),
1767 'pending_shipments': order.pending_shipment_count,
1768 }
1770 def validate(self, data):
1771 """Custom validation for the serializer."""
1772 data = super().validate(data)
1773 self.order.can_complete(
1774 raise_error=True,
1775 allow_incomplete_lines=str2bool(data.get('accept_incomplete', False)),
1776 )
1778 return data
1780 def save(self):
1781 """Save the serializer to complete the SalesOrder."""
1782 request = self.context.get('request')
1783 data = self.validated_data
1785 user = request.user if request else None
1787 self.order.ship_order(
1788 user, allow_incomplete_lines=str2bool(data.get('accept_incomplete', False))
1789 )
1792class SalesOrderHoldSerializer(OrderAdjustSerializer):
1793 """Serializer for placing a SalesOrder on hold."""
1795 def save(self):
1796 """Save the serializer to place the SalesOrder on hold."""
1797 self.order.hold_order()
1800class SalesOrderCancelSerializer(OrderAdjustSerializer):
1801 """Serializer for marking a SalesOrder as cancelled."""
1803 def get_context_data(self):
1804 """Add extra context data to the serializer."""
1805 order = self.context['order']
1807 return {'can_cancel': order.can_cancel}
1809 def save(self):
1810 """Save the serializer to cancel the order."""
1811 self.order.cancel_order()
1814class SalesOrderSerialAllocationSerializer(serializers.Serializer):
1815 """DRF serializer for allocation of serial numbers against a sales order / shipment."""
1817 class Meta:
1818 """Metaclass options."""
1820 fields = ['line_item', 'quantity', 'serial_numbers', 'shipment']
1822 line_item = serializers.PrimaryKeyRelatedField(
1823 queryset=order.models.SalesOrderLineItem.objects.all(),
1824 many=False,
1825 required=True,
1826 allow_null=False,
1827 label=_('Line Item'),
1828 )
1830 def validate_line_item(self, line_item):
1831 """Ensure that the line_item is valid."""
1832 order = self.context['order']
1834 # Ensure that the line item points to the correct order
1835 if line_item.order != order:
1836 raise ValidationError(_('Line item is not associated with this order'))
1838 return line_item
1840 quantity = serializers.IntegerField(
1841 min_value=1, required=True, allow_null=False, label=_('Quantity')
1842 )
1844 serial_numbers = serializers.CharField(
1845 label=_('Serial Numbers'),
1846 help_text=_('Enter serial numbers to allocate'),
1847 required=True,
1848 allow_blank=False,
1849 )
1851 shipment = serializers.PrimaryKeyRelatedField(
1852 queryset=order.models.SalesOrderShipment.objects.all(),
1853 many=False,
1854 required=False,
1855 allow_null=True,
1856 label=_('Shipment'),
1857 )
1859 def validate_shipment(self, shipment):
1860 """Validate the shipment.
1862 - Must point to the same order
1863 - Must not be shipped
1864 """
1865 order = self.context['order']
1867 if shipment and shipment.shipment_date is not None:
1868 raise ValidationError(_('Shipment has already been shipped'))
1870 if shipment and shipment.order != order:
1871 raise ValidationError(_('Shipment is not associated with this order'))
1873 return shipment
1875 def validate(self, data):
1876 """Validation for the serializer.
1878 - Ensure the serial_numbers and quantity fields match
1879 - Check that all serial numbers exist
1880 - Check that the serial numbers are not yet allocated
1881 """
1882 data = super().validate(data)
1884 line_item = data['line_item']
1885 quantity = data['quantity']
1886 serial_numbers = data['serial_numbers']
1888 part = line_item.part
1890 try:
1891 data['serials'] = extract_serial_numbers(
1892 serial_numbers, quantity, part.get_latest_serial_number(), part=part
1893 )
1894 except DjangoValidationError as e:
1895 raise ValidationError({'serial_numbers': e.messages})
1897 serials_not_exist = set()
1898 serials_unavailable = set()
1899 stock_items_to_allocate = []
1901 for serial in data['serials']:
1902 serial = str(serial).strip()
1904 items = stock.models.StockItem.objects.filter(
1905 part=part, serial=serial, quantity=1
1906 )
1908 if not items.exists():
1909 serials_not_exist.add(str(serial))
1910 continue
1912 stock_item = items[0]
1914 if get_global_setting('SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS'):
1915 if (
1916 stock_item.hasRequiredTests()
1917 and not stock_item.passedAllRequiredTests()
1918 ):
1919 serials_unavailable.add(str(serial))
1920 continue
1922 if not stock_item.in_stock:
1923 serials_unavailable.add(str(serial))
1924 continue
1926 if stock_item.unallocated_quantity() < 1:
1927 serials_unavailable.add(str(serial))
1928 continue
1930 # At this point, the serial number is valid, and can be added to the list
1931 stock_items_to_allocate.append(stock_item)
1933 if len(serials_not_exist) > 0:
1934 error_msg = _('No match found for the following serial numbers')
1935 error_msg += ': '
1936 error_msg += ','.join(sorted(serials_not_exist))
1938 raise ValidationError({'serial_numbers': error_msg})
1940 if len(serials_unavailable) > 0:
1941 error_msg = _('The following serial numbers are unavailable')
1942 error_msg += ': '
1943 error_msg += ','.join(sorted(serials_unavailable))
1945 raise ValidationError({'serial_numbers': error_msg})
1947 data['stock_items'] = stock_items_to_allocate
1949 return data
1951 def save(self):
1952 """Allocate stock items against the sales order."""
1953 data = self.validated_data
1955 line_item = data['line_item']
1956 stock_items = data['stock_items']
1957 shipment = data.get('shipment', None)
1959 allocations = []
1961 for stock_item in stock_items:
1962 # Create a new SalesOrderAllocation
1963 allocations.append(
1964 order.models.SalesOrderAllocation(
1965 line=line_item, item=stock_item, quantity=1, shipment=shipment
1966 )
1967 )
1969 with transaction.atomic():
1970 order.models.SalesOrderAllocation.objects.bulk_create(
1971 allocations, batch_size=250
1972 )
1975class SalesOrderShipmentAllocationSerializer(serializers.Serializer):
1976 """DRF serializer for allocation of stock items against a sales order / shipment."""
1978 class Meta:
1979 """Metaclass options."""
1981 fields = ['items', 'shipment']
1983 items = SalesOrderShipmentAllocationItemSerializer(many=True)
1985 shipment = serializers.PrimaryKeyRelatedField(
1986 queryset=order.models.SalesOrderShipment.objects.all(),
1987 many=False,
1988 required=False,
1989 allow_null=True,
1990 label=_('Shipment'),
1991 )
1993 def validate_shipment(self, shipment):
1994 """Run validation against the provided shipment instance."""
1995 order = self.context['order']
1997 if shipment and shipment.shipment_date is not None:
1998 raise ValidationError(_('Shipment has already been shipped'))
2000 if shipment and shipment.order != order:
2001 raise ValidationError(_('Shipment is not associated with this order'))
2003 return shipment
2005 def validate(self, data):
2006 """Serializer validation."""
2007 data = super().validate(data)
2009 # Extract SalesOrder from serializer context
2010 # order = self.context['order']
2012 items = data.get('items', [])
2014 if len(items) == 0: 2014 ↛ 2017line 2014 didn't jump to line 2017 because the condition on line 2014 was always true
2015 raise ValidationError(_('Allocation items must be provided'))
2017 return data
2019 def save(self):
2020 """Perform the allocation of items against this order."""
2021 data = self.validated_data
2023 items = data['items']
2024 shipment = data.get('shipment')
2026 with transaction.atomic():
2027 for entry in items:
2028 # Create a new SalesOrderAllocation
2029 allocation = order.models.SalesOrderAllocation(
2030 line=entry.get('line_item'),
2031 item=entry.get('stock_item'),
2032 quantity=entry.get('quantity'),
2033 shipment=shipment,
2034 )
2036 allocation.full_clean()
2037 allocation.save()
2040class SalesOrderAutoAllocationSerializer(serializers.Serializer):
2041 """DRF serializer for auto-allocating stock items against a SalesOrder."""
2043 class Meta:
2044 """Serializer metaclass."""
2046 fields = [
2047 'location',
2048 'exclude_location',
2049 'shipment',
2050 'interchangeable',
2051 'stock_sort_by',
2052 'serialized_stock',
2053 'line_items',
2054 ]
2056 location = serializers.PrimaryKeyRelatedField(
2057 queryset=stock.models.StockLocation.objects.all(),
2058 many=False,
2059 allow_null=True,
2060 required=False,
2061 label=_('Source Location'),
2062 help_text=_(
2063 'Stock location where items are sourced (leave blank to use any location)'
2064 ),
2065 )
2067 exclude_location = serializers.PrimaryKeyRelatedField(
2068 queryset=stock.models.StockLocation.objects.all(),
2069 many=False,
2070 allow_null=True,
2071 required=False,
2072 label=_('Exclude Location'),
2073 help_text=_('Exclude stock items from this location'),
2074 )
2076 shipment = serializers.PrimaryKeyRelatedField(
2077 queryset=order.models.SalesOrderShipment.objects.all(),
2078 many=False,
2079 allow_null=True,
2080 required=False,
2081 label=_('Shipment'),
2082 help_text=_('Assign allocations to this shipment'),
2083 )
2085 interchangeable = serializers.BooleanField(
2086 default=True,
2087 label=_('Interchangeable Stock'),
2088 help_text=_(
2089 'Allow stock to be taken from multiple locations to fulfil a single line item'
2090 ),
2091 )
2093 stock_sort_by = serializers.ChoiceField(
2094 default=stock.models.STOCK_SORT_DEFAULT,
2095 choices=stock.models.STOCK_SORT_CHOICES,
2096 label=_('Stock Priority'),
2097 help_text=_('Preferred order in which matching stock items are consumed'),
2098 )
2100 serialized_stock = serializers.ChoiceField(
2101 default=order.models.SERIALIZED_STOCK_DEFAULT,
2102 choices=order.models.SERIALIZED_STOCK_CHOICES,
2103 label=_('Serialized Stock'),
2104 help_text=_(
2105 'Control whether serialized stock items are included in auto-allocation'
2106 ),
2107 )
2109 line_items = serializers.PrimaryKeyRelatedField(
2110 queryset=order.models.SalesOrderLineItem.objects.all(),
2111 many=True,
2112 required=False,
2113 default=list,
2114 label=_('Line Items'),
2115 help_text=_(
2116 'Limit allocation to these line items (leave blank to allocate all lines)'
2117 ),
2118 )
2120 def validate_shipment(self, shipment):
2121 """Validate that the shipment belongs to this order and is not yet shipped."""
2122 order_obj = self.context.get('order')
2124 if shipment is None:
2125 return shipment
2127 if shipment.shipment_date is not None:
2128 raise ValidationError(_('Shipment has already been shipped'))
2130 if order_obj and shipment.order != order_obj:
2131 raise ValidationError(_('Shipment is not associated with this order'))
2133 return shipment
2135 def validate_line_items(self, line_items):
2136 """Validate that all provided line items belong to this order."""
2137 order_obj = self.context.get('order')
2139 if order_obj and line_items:
2140 for line in line_items:
2141 if line.order != order_obj:
2142 raise ValidationError(_('Line item does not belong to this order'))
2144 return line_items
2147@register_importer()
2148class SalesOrderExtraLineSerializer(
2149 AbstractExtraLineSerializer, InvenTreeModelSerializer
2150):
2151 """Serializer for a SalesOrderExtraLine object."""
2153 class Meta(AbstractExtraLineMeta):
2154 """Metaclass options."""
2156 model = order.models.SalesOrderExtraLine
2157 fields = AbstractExtraLineSerializer.extra_line_fields([])
2159 order_detail = OptionalField(
2160 serializer_class=SalesOrderSerializer,
2161 serializer_kwargs={
2162 'source': 'order',
2163 'many': False,
2164 'read_only': True,
2165 'allow_null': True,
2166 },
2167 )
2170@register_importer()
2171class ReturnOrderSerializer(
2172 NotesFieldMixin,
2173 InvenTreeCustomStatusSerializerMixin,
2174 AbstractOrderSerializer,
2175 TotalPriceMixin,
2176 InvenTreeModelSerializer,
2177):
2178 """Serializer for the ReturnOrder model class."""
2180 class Meta:
2181 """Metaclass options."""
2183 model = order.models.ReturnOrder
2184 fields = AbstractOrderSerializer.order_fields([
2185 'complete_date',
2186 'customer',
2187 'customer_detail',
2188 'customer_reference',
2189 'order_currency',
2190 'total_price',
2191 'updated_at',
2192 ])
2193 read_only_fields = ['creation_date', 'updated_at']
2195 def skip_create_fields(self):
2196 """Skip these fields when instantiating a new object."""
2197 fields = super().skip_create_fields()
2199 return [*fields, 'duplicate']
2201 @staticmethod
2202 def annotate_queryset(queryset):
2203 """Custom annotation for the serializer queryset."""
2204 queryset = AbstractOrderSerializer.annotate_queryset(queryset)
2206 queryset = queryset.annotate(
2207 completed_lines=SubqueryCount(
2208 'lines', filter=~Q(outcome=ReturnOrderLineStatus.PENDING.value)
2209 )
2210 )
2212 queryset = queryset.annotate(
2213 overdue=Case(
2214 When(
2215 order.models.ReturnOrder.overdue_filter(),
2216 then=Value(True, output_field=BooleanField()),
2217 ),
2218 default=Value(False, output_field=BooleanField()),
2219 )
2220 )
2222 return queryset
2224 customer_detail = OptionalField(
2225 serializer_class=CompanyBriefSerializer,
2226 serializer_kwargs={
2227 'source': 'customer',
2228 'many': False,
2229 'read_only': True,
2230 'allow_null': True,
2231 },
2232 prefetch_fields=['customer'],
2233 )
2236class ReturnOrderHoldSerializer(OrderAdjustSerializer):
2237 """Serializers for holding a ReturnOrder."""
2239 def save(self):
2240 """Save the serializer to 'hold' the order."""
2241 self.order.hold_order()
2244class ReturnOrderIssueSerializer(OrderAdjustSerializer):
2245 """Serializer for issuing a ReturnOrder."""
2247 def save(self):
2248 """Save the serializer to 'issue' the order."""
2249 self.order.issue_order()
2252class ReturnOrderCancelSerializer(OrderAdjustSerializer):
2253 """Serializer for cancelling a ReturnOrder."""
2255 def save(self):
2256 """Save the serializer to 'cancel' the order."""
2257 self.order.cancel_order()
2260class ReturnOrderCompleteSerializer(OrderAdjustSerializer):
2261 """Serializer for completing a ReturnOrder."""
2263 def save(self):
2264 """Save the serializer to 'complete' the order."""
2265 self.order.complete_order()
2268class ReturnOrderLineItemReceiveSerializer(serializers.Serializer):
2269 """Serializer for receiving a single line item against a ReturnOrder."""
2271 class Meta:
2272 """Metaclass options."""
2274 fields = ['item', 'status']
2276 item = serializers.PrimaryKeyRelatedField(
2277 queryset=order.models.ReturnOrderLineItem.objects.all(),
2278 many=False,
2279 allow_null=False,
2280 required=True,
2281 label=_('Return order line item'),
2282 )
2284 status = stock.serializers.StockStatusCustomSerializer(
2285 default=None, required=False, allow_blank=True
2286 )
2288 def validate_line_item(self, item):
2289 """Validation for a single line item."""
2290 if item.order != self.context['order']:
2291 raise ValidationError(_('Line item does not match return order'))
2293 if item.received:
2294 raise ValidationError(_('Line item has already been received'))
2296 return item
2299class ReturnOrderReceiveSerializer(serializers.Serializer):
2300 """Serializer for receiving items against a ReturnOrder."""
2302 class Meta:
2303 """Metaclass options."""
2305 fields = ['items', 'location', 'note']
2307 items = ReturnOrderLineItemReceiveSerializer(many=True)
2309 location = serializers.PrimaryKeyRelatedField(
2310 queryset=stock.models.StockLocation.objects.all(),
2311 many=False,
2312 allow_null=False,
2313 required=True,
2314 label=_('Location'),
2315 help_text=_('Select destination location for received items'),
2316 )
2318 note = serializers.CharField(
2319 label=_('Note'),
2320 help_text=_('Additional note for incoming stock items'),
2321 required=False,
2322 default='',
2323 allow_blank=True,
2324 )
2326 def validate(self, data):
2327 """Perform data validation for this serializer."""
2328 order = self.context['order']
2329 if order.status != ReturnOrderStatus.IN_PROGRESS:
2330 raise ValidationError(
2331 _('Items can only be received against orders which are in progress')
2332 )
2334 data = super().validate(data)
2336 items = data.get('items', [])
2338 if len(items) == 0:
2339 raise ValidationError(_('Line items must be provided'))
2341 return data
2343 @transaction.atomic
2344 def save(self):
2345 """Saving this serializer marks the returned items as received."""
2346 order = self.context['order']
2347 request = self.context.get('request')
2349 data = self.validated_data
2350 items = data['items']
2351 location = data['location']
2353 with transaction.atomic():
2354 for item in items:
2355 line_item = item['item']
2357 order.receive_line_item(
2358 line_item,
2359 location,
2360 request.user if request else None,
2361 note=data.get('note', ''),
2362 status=item.get('status', None),
2363 )
2366@register_importer()
2367class ReturnOrderLineItemSerializer(
2368 DataImportExportSerializerMixin,
2369 AbstractLineItemSerializer,
2370 InvenTreeModelSerializer,
2371):
2372 """Serializer for a ReturnOrderLineItem object."""
2374 class Meta:
2375 """Metaclass options."""
2377 model = order.models.ReturnOrderLineItem
2378 fields = AbstractLineItemSerializer.line_fields([
2379 'item',
2380 'received_date',
2381 'outcome',
2382 'price',
2383 'price_currency',
2384 # Filterable detail fields
2385 'item_detail',
2386 'part_detail',
2387 ])
2389 order_detail = OptionalField(
2390 serializer_class=ReturnOrderSerializer,
2391 serializer_kwargs={
2392 'source': 'order',
2393 'many': False,
2394 'read_only': True,
2395 'allow_null': True,
2396 },
2397 prefetch_fields=[
2398 'order__created_by',
2399 'order__responsible',
2400 'order__address',
2401 'order__project_code',
2402 'order__contact',
2403 ],
2404 )
2406 quantity = serializers.FloatField(
2407 label=_('Quantity'), help_text=_('Quantity to return')
2408 )
2410 item_detail = OptionalField(
2411 serializer_class=stock.serializers.StockItemSerializer,
2412 serializer_kwargs={
2413 'source': 'item',
2414 'many': False,
2415 'read_only': True,
2416 'allow_null': True,
2417 },
2418 prefetch_fields=['item__supplier_part'],
2419 )
2421 part_detail = OptionalField(
2422 serializer_class=PartBriefSerializer,
2423 serializer_kwargs={
2424 'source': 'item.part',
2425 'many': False,
2426 'read_only': True,
2427 'allow_null': True,
2428 },
2429 )
2431 price = InvenTreeMoneySerializer(allow_null=True)
2432 price_currency = InvenTreeCurrencySerializer(help_text=_('Line price currency'))
2435@register_importer()
2436class ReturnOrderExtraLineSerializer(
2437 AbstractExtraLineSerializer, InvenTreeModelSerializer
2438):
2439 """Serializer for a ReturnOrderExtraLine object."""
2441 class Meta(AbstractExtraLineMeta):
2442 """Metaclass options."""
2444 model = order.models.ReturnOrderExtraLine
2445 fields = AbstractExtraLineSerializer.extra_line_fields([])
2447 order_detail = OptionalField(
2448 serializer_class=ReturnOrderSerializer,
2449 serializer_kwargs={
2450 'source': 'order',
2451 'many': False,
2452 'read_only': True,
2453 'allow_null': True,
2454 },
2455 )
2458@register_importer()
2459class TransferOrderSerializer(
2460 NotesFieldMixin,
2461 InvenTreeCustomStatusSerializerMixin,
2462 AbstractOrderSerializer,
2463 InvenTreeModelSerializer,
2464):
2465 """Serializer for a TransferOrder object."""
2467 class Meta:
2468 """Metaclass options."""
2470 model = order.models.TransferOrder
2471 fields = AbstractOrderSerializer.order_fields([
2472 'take_from',
2473 'take_from_detail',
2474 'destination',
2475 'destination_detail',
2476 'consume',
2477 'complete_date',
2478 ])
2479 read_only_fields = ['creation_date']
2480 extra_kwargs = {}
2482 def skip_create_fields(self):
2483 """Skip these fields when instantiating a new object."""
2484 fields = super().skip_create_fields()
2486 return [*fields, 'duplicate']
2488 @staticmethod
2489 def annotate_queryset(queryset):
2490 """Add extra information to the queryset.
2492 - Number of line items in the TransferOrder
2493 - Number of completed line items in the TransferOrder
2494 - Overdue status of the TransferOrder
2495 """
2496 queryset = AbstractOrderSerializer.annotate_queryset(queryset)
2498 queryset = queryset.annotate(
2499 completed_lines=SubqueryCount(
2500 'lines', filter=Q(quantity__lte=F('transferred'))
2501 )
2502 )
2504 queryset = queryset.annotate(
2505 overdue=Case(
2506 When(
2507 order.models.TransferOrder.overdue_filter(),
2508 then=Value(True, output_field=BooleanField()),
2509 ),
2510 default=Value(False, output_field=BooleanField()),
2511 )
2512 )
2514 return queryset
2516 take_from_detail = OptionalField(
2517 serializer_class=stock.serializers.LocationSerializer,
2518 serializer_kwargs={
2519 'source': 'take_from',
2520 'many': False,
2521 'read_only': True,
2522 'allow_null': True,
2523 },
2524 default_include=True,
2525 )
2527 destination_detail = OptionalField(
2528 serializer_class=stock.serializers.LocationSerializer,
2529 serializer_kwargs={
2530 'source': 'destination',
2531 'many': False,
2532 'read_only': True,
2533 'allow_null': True,
2534 },
2535 default_include=True,
2536 )
2539class TransferOrderHoldSerializer(OrderAdjustSerializer):
2540 """Serializer for placing a TransferOrder on hold."""
2542 def save(self):
2543 """Save the serializer to 'hold' the order."""
2544 self.order.hold_order()
2547class TransferOrderIssueSerializer(OrderAdjustSerializer):
2548 """Serializer for issuing a transfer order."""
2550 def save(self):
2551 """Save the serializer to 'issue' the order."""
2552 self.order.issue_order()
2555class TransferOrderCancelSerializer(OrderAdjustSerializer):
2556 """Serializer for cancelling a TransferOrder."""
2558 def save(self):
2559 """Save the serializer to 'cancel' the order."""
2560 if not self.order.can_cancel: 2560 ↛ 2561line 2560 didn't jump to line 2561 because the condition on line 2560 was never true
2561 raise ValidationError(_('Order cannot be cancelled'))
2563 self.order.cancel_order()
2566class TransferOrderCompleteSerializer(OrderAdjustSerializer):
2567 """Serializer for completing a transfer order."""
2569 class Meta:
2570 """Metaclass options."""
2572 fields = ['accept_incomplete_allocation']
2574 accept_incomplete_allocation = serializers.BooleanField(
2575 label=_('Accept Incomplete Allocation'),
2576 help_text=_('Allow order to complete with incomplete allocations'),
2577 required=False,
2578 default=False,
2579 )
2581 def validate_accept_incomplete_allocation(self, value):
2582 """Check if the 'accept_incomplete_allocation' field is required."""
2583 order = self.context['order']
2585 if not value and not order.is_fully_allocated(): 2585 ↛ 2586line 2585 didn't jump to line 2586 because the condition on line 2585 was never true
2586 raise ValidationError(_('Order has incomplete allocations'))
2588 return value
2590 def get_context_data(self):
2591 """Custom context information for this serializer."""
2592 order = self.context['order']
2594 return {'is_complete': order.is_completed()}
2596 def validate(self, data):
2597 """Custom validation for the serializer."""
2598 data = super().validate(data)
2599 self.order.can_complete(
2600 raise_error=True,
2601 allow_incomplete_lines=str2bool(
2602 data.get('accept_incomplete_allocation', False)
2603 ),
2604 )
2605 return data
2607 def save(self):
2608 """Save the serializer to 'complete' the order."""
2609 request = self.context.get('request')
2610 data = self.validated_data
2611 user = request.user if request else None
2613 self.order.complete_order(
2614 user=user,
2615 allow_incomplete_lines=data.get('accept_incomplete_allocation', False),
2616 )
2619@register_importer()
2620class TransferOrderLineItemSerializer(
2621 DataImportExportSerializerMixin,
2622 AbstractLineItemSerializer,
2623 InvenTreeModelSerializer,
2624):
2625 """Serializer for a TransferOrderLineItem object."""
2627 class Meta:
2628 """Metaclass options."""
2630 model = order.models.TransferOrderLineItem
2631 fields = AbstractLineItemSerializer.line_fields([
2632 'allocated',
2633 'overdue',
2634 'part',
2635 'part_detail',
2636 'transferred',
2637 # Annotated fields for part stocking information
2638 'available_stock',
2639 'available_variant_stock',
2640 'building',
2641 'on_order',
2642 # Filterable detail fields
2643 ])
2645 @staticmethod
2646 def annotate_queryset(queryset):
2647 """Add some extra annotations to this queryset.
2649 - "overdue" status (boolean field)
2650 - "available_quantity"
2651 - "building"
2652 - "on_order"
2653 """
2654 queryset = queryset.annotate(
2655 overdue=Case(
2656 When(
2657 Q(order__status__in=TransferOrderStatusGroups.OPEN)
2658 & order.models.TransferOrderLineItem.OVERDUE_FILTER,
2659 then=Value(True, output_field=BooleanField()),
2660 ),
2661 default=Value(False, output_field=BooleanField()),
2662 )
2663 )
2665 # Annotate each line with the available stock quantity
2666 # To do this, we need to look at the total stock and any allocations
2667 queryset = queryset.alias(
2668 total_stock=part_filters.annotate_total_stock(reference='part__'),
2669 allocated_to_sales_orders=part_filters.annotate_sales_order_allocations(
2670 reference='part__'
2671 ),
2672 allocated_to_build_orders=part_filters.annotate_build_order_allocations(
2673 reference='part__'
2674 ),
2675 )
2677 queryset = queryset.annotate(
2678 available_stock=Greatest(
2679 ExpressionWrapper(
2680 F('total_stock')
2681 - F('allocated_to_sales_orders')
2682 - F('allocated_to_build_orders'),
2683 output_field=models.DecimalField(),
2684 ),
2685 0,
2686 output_field=models.DecimalField(),
2687 )
2688 )
2690 # Add information about the quantity of parts currently on order
2691 queryset = queryset.annotate(
2692 on_order=part_filters.annotate_on_order_quantity(reference='part__')
2693 )
2695 # Add information about the quantity of parts currently in production
2696 queryset = queryset.annotate(
2697 building=part_filters.annotate_in_production_quantity(reference='part__')
2698 )
2700 # Annotate total 'allocated' stock quantity
2701 queryset = queryset.annotate(
2702 allocated=Coalesce(
2703 SubquerySum('allocations__quantity'),
2704 Decimal(0),
2705 output_field=models.DecimalField(),
2706 )
2707 )
2709 return queryset
2711 order_detail = OptionalField(
2712 serializer_class=TransferOrderSerializer,
2713 serializer_kwargs={
2714 'source': 'order',
2715 'many': False,
2716 'read_only': True,
2717 'allow_null': True,
2718 },
2719 prefetch_fields=[
2720 'order__created_by',
2721 'order__responsible',
2722 'order__project_code',
2723 ],
2724 )
2726 part_detail = OptionalField(
2727 serializer_class=PartBriefSerializer,
2728 serializer_kwargs={
2729 'source': 'part',
2730 'many': False,
2731 'read_only': True,
2732 'allow_null': True,
2733 },
2734 prefetch_fields=['part__pricing_data'],
2735 )
2737 # Annotated fields
2738 overdue = serializers.BooleanField(read_only=True, allow_null=True)
2739 available_stock = serializers.FloatField(read_only=True)
2740 available_variant_stock = serializers.FloatField(read_only=True)
2741 on_order = serializers.FloatField(label=_('On Order'), read_only=True)
2742 building = serializers.FloatField(label=_('In Production'), read_only=True)
2744 quantity = InvenTreeDecimalField()
2746 allocated = serializers.FloatField(read_only=True)
2748 transferred = InvenTreeDecimalField(read_only=True)
2751class TransferOrderAllocationItemSerializer(serializers.Serializer):
2752 """A serializer for allocating a single stock-item against a TransferOrder line item."""
2754 class Meta:
2755 """Metaclass options."""
2757 fields = ['line_item', 'stock_item', 'quantity']
2759 line_item = serializers.PrimaryKeyRelatedField(
2760 queryset=order.models.TransferOrderLineItem.objects.all(),
2761 many=False,
2762 allow_null=False,
2763 required=True,
2764 label=_('Stock Item'),
2765 )
2767 def validate_line_item(self, line_item):
2768 """Custom validation for the 'line_item' field.
2770 - Ensure the line_item is associated with the particular TransferOrder
2771 """
2772 order = self.context['order']
2774 # Ensure that the line item points to the correct order
2775 if line_item.order != order:
2776 raise ValidationError(_('Line item is not associated with this order'))
2778 return line_item
2780 stock_item = serializers.PrimaryKeyRelatedField(
2781 queryset=stock.models.StockItem.objects.all(),
2782 many=False,
2783 allow_null=False,
2784 required=True,
2785 label=_('Stock Item'),
2786 )
2788 quantity = serializers.DecimalField(
2789 max_digits=15, decimal_places=5, min_value=Decimal(0), required=True
2790 )
2792 def validate_quantity(self, quantity):
2793 """Custom validation for the 'quantity' field."""
2794 if quantity <= 0: 2794 ↛ 2797line 2794 didn't jump to line 2797 because the condition on line 2794 was always true
2795 raise ValidationError(_('Quantity must be positive'))
2797 return quantity
2799 def validate(self, data):
2800 """Custom validation for the serializer.
2802 - Ensure that the quantity is 1 for serialized stock
2803 - Quantity cannot exceed the available amount
2804 """
2805 data = super().validate(data)
2807 stock_item = data['stock_item']
2808 quantity = data['quantity']
2810 if stock_item.serialized and quantity != 1:
2811 raise ValidationError({
2812 'quantity': _('Quantity must be 1 for serialized stock item')
2813 })
2815 q = normalize(stock_item.unallocated_quantity())
2817 if quantity > q:
2818 raise ValidationError({'quantity': _(f'Available quantity ({q}) exceeded')})
2820 return data
2823class TransferOrderLineItemAllocationSerializer(serializers.Serializer):
2824 """DRF serializer for allocation of stock items against a transfer order line item."""
2826 class Meta:
2827 """Metaclass options."""
2829 fields = ['items']
2831 items = TransferOrderAllocationItemSerializer(many=True)
2833 def validate(self, data):
2834 """Serializer validation."""
2835 data = super().validate(data)
2837 # Extract TransferOrder from serializer context
2838 # order = self.context['order']
2840 items = data.get('items', [])
2842 if len(items) == 0: 2842 ↛ 2845line 2842 didn't jump to line 2845 because the condition on line 2842 was always true
2843 raise ValidationError(_('Allocation items must be provided'))
2845 return data
2847 def save(self):
2848 """Perform the allocation of items against this order."""
2849 data = self.validated_data
2851 items = data['items']
2853 with transaction.atomic():
2854 for entry in items:
2855 # Create a new TransferOrderAllocation
2856 allocation = order.models.TransferOrderAllocation(
2857 line=entry.get('line_item'),
2858 item=entry.get('stock_item'),
2859 quantity=entry.get('quantity'),
2860 )
2862 allocation.full_clean()
2863 allocation.save()
2866class TransferOrderAllocationSerializer(
2867 FilterableSerializerMixin, InvenTreeModelSerializer
2868):
2869 """Serializer for the TransferOrderAllocation model.
2871 This includes some fields from the related model objects.
2872 """
2874 class Meta:
2875 """Metaclass options."""
2877 model = order.models.TransferOrderAllocation
2878 fields = [
2879 'pk',
2880 'item',
2881 'quantity',
2882 # Annotated read-only fields
2883 'line',
2884 'part',
2885 'order',
2886 'serial',
2887 'location',
2888 # Extra detail fields
2889 'item_detail',
2890 'part_detail',
2891 'order_detail',
2892 'location_detail',
2893 ]
2894 read_only_fields = ['line', '']
2896 part = serializers.PrimaryKeyRelatedField(source='item.part', read_only=True)
2897 order = serializers.PrimaryKeyRelatedField(
2898 source='line.order', many=False, read_only=True
2899 )
2900 serial = serializers.CharField(source='get_serial', read_only=True, allow_null=True)
2901 quantity = serializers.FloatField(read_only=False)
2902 location = serializers.PrimaryKeyRelatedField(
2903 source='item.location', many=False, read_only=True
2904 )
2906 # Extra detail fields
2907 order_detail = OptionalField(
2908 serializer_class=TransferOrderSerializer,
2909 serializer_kwargs={
2910 'source': 'line.order',
2911 'many': False,
2912 'read_only': True,
2913 'allow_null': True,
2914 },
2915 )
2917 part_detail = OptionalField(
2918 serializer_class=PartBriefSerializer,
2919 serializer_kwargs={
2920 'source': 'item.part',
2921 'many': False,
2922 'read_only': True,
2923 'allow_null': True,
2924 },
2925 )
2927 item_detail = OptionalField(
2928 serializer_class=stock.serializers.StockItemSerializer,
2929 serializer_kwargs={
2930 'source': 'item',
2931 'many': False,
2932 'read_only': True,
2933 'allow_null': True,
2934 'part_detail': False,
2935 'location_detail': False,
2936 'supplier_part_detail': False,
2937 },
2938 )
2940 location_detail = OptionalField(
2941 serializer_class=stock.serializers.LocationBriefSerializer,
2942 serializer_kwargs={
2943 'source': 'item.location',
2944 'many': False,
2945 'read_only': True,
2946 'allow_null': True,
2947 },
2948 )
2951class TransferOrderSerialAllocationSerializer(serializers.Serializer):
2952 """DRF serializer for allocation of serial numbers against a transfer order."""
2954 class Meta:
2955 """Metaclass options."""
2957 fields = ['line_item', 'quantity', 'serial_numbers']
2959 line_item = serializers.PrimaryKeyRelatedField(
2960 queryset=order.models.TransferOrderLineItem.objects.all(),
2961 many=False,
2962 required=True,
2963 allow_null=False,
2964 label=_('Line Item'),
2965 )
2967 def validate_line_item(self, line_item):
2968 """Ensure that the line_item is valid."""
2969 order = self.context['order']
2971 # Ensure that the line item points to the correct order
2972 if line_item.order != order:
2973 raise ValidationError(_('Line item is not associated with this order'))
2975 return line_item
2977 quantity = serializers.IntegerField(
2978 min_value=1, required=True, allow_null=False, label=_('Quantity')
2979 )
2981 serial_numbers = serializers.CharField(
2982 label=_('Serial Numbers'),
2983 help_text=_('Enter serial numbers to allocate'),
2984 required=True,
2985 allow_blank=False,
2986 )
2988 def validate(self, data):
2989 """Validation for the serializer.
2991 - Ensure the serial_numbers and quantity fields match
2992 - Check that all serial numbers exist
2993 - Check that the serial numbers are not yet allocated
2994 """
2995 data = super().validate(data)
2997 line_item = data['line_item']
2998 quantity = data['quantity']
2999 serial_numbers = data['serial_numbers']
3001 part = line_item.part
3003 try:
3004 data['serials'] = extract_serial_numbers(
3005 serial_numbers, quantity, part.get_latest_serial_number(), part=part
3006 )
3007 except DjangoValidationError as e:
3008 raise ValidationError({'serial_numbers': e.messages})
3010 serials_not_exist = set()
3011 serials_unavailable = set()
3012 stock_items_to_allocate = []
3014 for serial in data['serials']:
3015 serial = str(serial).strip()
3017 items = stock.models.StockItem.objects.filter(
3018 part=part, serial=serial, quantity=1
3019 )
3021 if not items.exists():
3022 serials_not_exist.add(str(serial))
3023 continue
3025 stock_item = items[0]
3027 if not stock_item.in_stock:
3028 serials_unavailable.add(str(serial))
3029 continue
3031 if stock_item.unallocated_quantity() < 1:
3032 serials_unavailable.add(str(serial))
3033 continue
3035 # At this point, the serial number is valid, and can be added to the list
3036 stock_items_to_allocate.append(stock_item)
3038 if len(serials_not_exist) > 0:
3039 error_msg = _('No match found for the following serial numbers')
3040 error_msg += ': '
3041 error_msg += ','.join(sorted(serials_not_exist))
3043 raise ValidationError({'serial_numbers': error_msg})
3045 if len(serials_unavailable) > 0:
3046 error_msg = _('The following serial numbers are unavailable')
3047 error_msg += ': '
3048 error_msg += ','.join(sorted(serials_unavailable))
3050 raise ValidationError({'serial_numbers': error_msg})
3052 data['stock_items'] = stock_items_to_allocate
3054 return data
3056 def save(self):
3057 """Allocate stock items against the transfer order."""
3058 data = self.validated_data
3060 line_item = data['line_item']
3061 stock_items = data['stock_items']
3063 allocations = []
3065 for stock_item in stock_items:
3066 # Create a new TransferOrderAllocation
3067 allocations.append(
3068 order.models.TransferOrderAllocation(
3069 line=line_item, item=stock_item, quantity=1
3070 )
3071 )
3073 with transaction.atomic():
3074 order.models.TransferOrderAllocation.objects.bulk_create(allocations)