Coverage for src/backend/InvenTree/build/serializers.py: 49%
478 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 Build API."""
3from decimal import Decimal
5from django.core.exceptions import ValidationError as DjangoValidationError
6from django.db import models, transaction
7from django.db.models import (
8 BooleanField,
9 Case,
10 ExpressionWrapper,
11 F,
12 FloatField,
13 Q,
14 Sum,
15 Value,
16 When,
17)
18from django.db.models.functions import Coalesce, Greatest
19from django.utils.translation import gettext_lazy as _
21from rest_framework import serializers
22from rest_framework.serializers import ValidationError
24import common.filters
25import common.settings
26import company.serializers
27import InvenTree.helpers
28import part.filters
29import part.serializers as part_serializers
30import stock.models as stock_models
31from common.settings import get_global_setting
32from generic.states.fields import InvenTreeCustomStatusSerializerMixin
33from InvenTree.mixins import DataImportExportSerializerMixin
34from InvenTree.serializers import (
35 CustomStatusSerializerMixin,
36 FilterableSerializerMixin,
37 InvenTreeDecimalField,
38 InvenTreeModelSerializer,
39 InvenTreeTaggitSerializer,
40 NotesFieldMixin,
41 OptionalField,
42)
43from stock.generators import generate_batch_code
44from stock.models import StockItem, StockLocation
45from stock.serializers import (
46 LocationBriefSerializer,
47 StockItemSerializer,
48 StockStatusCustomSerializer,
49)
50from stock.status_codes import StockStatus
51from users.serializers import OwnerSerializer, UserSerializer
53from .models import Build, BuildItem, BuildLine
54from .status_codes import BuildStatus
57class BuildSerializer(
58 CustomStatusSerializerMixin,
59 FilterableSerializerMixin,
60 NotesFieldMixin,
61 InvenTreeTaggitSerializer,
62 DataImportExportSerializerMixin,
63 InvenTreeCustomStatusSerializerMixin,
64 InvenTreeModelSerializer,
65):
66 """Serializes a Build object."""
68 class Meta:
69 """Serializer metaclass."""
71 model = Build
72 fields = [
73 'pk',
74 'title',
75 'barcode_hash',
76 'batch',
77 'creation_date',
78 'completed',
79 'completion_date',
80 'destination',
81 'external',
82 'parent',
83 'part',
84 'part_name',
85 'part_detail',
86 'project_code',
87 'project_code_label',
88 'project_code_detail',
89 'overdue',
90 'reference',
91 'sales_order',
92 'quantity',
93 'start_date',
94 'status',
95 'status_text',
96 'status_custom_key',
97 'target_date',
98 'take_from',
99 'notes',
100 'link',
101 'issued_by',
102 'issued_by_detail',
103 'responsible',
104 'responsible_detail',
105 'parameters',
106 'priority',
107 'level',
108 'tags',
109 ]
110 read_only_fields = [
111 'completed',
112 'creation_date',
113 'issued_by',
114 'completion_date',
115 'status',
116 'status_text',
117 'level',
118 ]
120 reference = serializers.CharField(required=True)
122 level = serializers.IntegerField(label=_('Build Level'), read_only=True)
124 part_detail = OptionalField(
125 serializer_class=part_serializers.PartBriefSerializer,
126 serializer_kwargs={'source': 'part', 'many': False, 'read_only': True},
127 default_include=True,
128 prefetch_fields=['part', 'part__category', 'part__pricing_data'],
129 )
131 parameters = common.filters.enable_parameters_filter()
133 tags = common.filters.enable_tags_filter()
135 part_name = serializers.CharField(
136 source='part.name', read_only=True, label=_('Part Name')
137 )
139 quantity = InvenTreeDecimalField()
141 overdue = serializers.BooleanField(read_only=True, default=False)
143 issued_by_detail = OptionalField(
144 serializer_class=UserSerializer,
145 serializer_kwargs={'source': 'issued_by', 'read_only': True},
146 default_include=True,
147 filter_name='user_detail',
148 prefetch_fields=['issued_by'],
149 )
151 responsible_detail = OptionalField(
152 serializer_class=OwnerSerializer,
153 serializer_kwargs={
154 'source': 'responsible',
155 'read_only': True,
156 'allow_null': True,
157 },
158 default_include=True,
159 filter_name='user_detail',
160 prefetch_fields=['responsible'],
161 )
163 barcode_hash = serializers.CharField(read_only=True)
165 project_code_label = common.filters.enable_project_label_filter()
167 project_code_detail = common.filters.enable_project_code_filter()
169 @staticmethod
170 def annotate_queryset(queryset):
171 """Add custom annotations to the BuildSerializer queryset, performing database queries as efficiently as possible.
173 The following annotated fields are added:
175 - overdue: True if the build is outstanding *and* the completion date has past
176 """
177 # Annotate a boolean 'overdue' flag
179 queryset = queryset.annotate(
180 overdue=Case(
181 When(
182 Build.get_overdue_filter(),
183 then=Value(True, output_field=BooleanField()),
184 ),
185 default=Value(False, output_field=BooleanField()),
186 )
187 )
189 return queryset
191 def __init__(self, *args, **kwargs):
192 """Determine if extra serializer fields are required."""
193 kwargs.pop('create', False)
195 super().__init__(*args, **kwargs)
197 def validate_reference(self, reference):
198 """Custom validation for the Build reference field."""
199 # Ensure the reference matches the required pattern
200 Build.validate_reference_field(reference)
202 return reference
205class BuildOutputSerializer(serializers.Serializer):
206 """Serializer for a "BuildOutput".
208 Note that a "BuildOutput" is really just a StockItem which is "in production"!
209 """
211 class Meta:
212 """Serializer metaclass."""
214 fields = ['output']
216 output = serializers.PrimaryKeyRelatedField(
217 queryset=StockItem.objects.all(),
218 many=False,
219 allow_null=False,
220 required=True,
221 label=_('Build Output'),
222 )
224 def validate_output(self, output):
225 """Perform validation for the output (StockItem) provided to the serializer."""
226 build = self.context['build']
228 # As this serializer can be used in multiple contexts, we need to work out why we are here
229 to_complete = self.context.get('to_complete', False)
231 # The stock item must point to the build
232 if output.build != build:
233 raise ValidationError(_('Build output does not match the parent build'))
235 # The part must match!
236 if output.part != build.part:
237 raise ValidationError(_('Output part does not match BuildOrder part'))
239 # The build output must be "in production"
240 if not output.is_building:
241 raise ValidationError(_('This build output has already been completed'))
243 if to_complete:
244 # The build output must have all tracked parts allocated
245 if not build.is_output_fully_allocated(output):
246 # Check if the user has specified that incomplete allocations are ok
247 if request := self.context.get('request'):
248 accept_incomplete = InvenTree.helpers.str2bool(
249 request.data.get('accept_incomplete_allocation', False)
250 )
251 else:
252 accept_incomplete = False
254 if not accept_incomplete:
255 raise ValidationError(_('This build output is not fully allocated'))
257 return output
260class BuildOutputQuantitySerializer(BuildOutputSerializer):
261 """Build output with quantity field."""
263 class Meta:
264 """Serializer metaclass."""
266 fields = [*BuildOutputSerializer.Meta.fields, 'quantity']
268 quantity = serializers.DecimalField(
269 max_digits=15,
270 decimal_places=5,
271 min_value=Decimal(0),
272 required=False,
273 label=_('Quantity'),
274 help_text=_('Enter quantity for build output'),
275 )
277 def validate(self, data):
278 """Validate the serializer data."""
279 data = super().validate(data)
281 output = data.get('output')
282 quantity = data.get('quantity')
284 if quantity is not None:
285 if quantity <= 0:
286 raise ValidationError({
287 'quantity': _('Quantity must be greater than zero')
288 })
290 if quantity > output.quantity:
291 raise ValidationError({
292 'quantity': _('Quantity cannot be greater than the output quantity')
293 })
295 return data
298class BuildOutputCreateSerializer(serializers.Serializer):
299 """Serializer for creating a new BuildOutput against a BuildOrder.
301 URL pattern is "/api/build/<pk>/create-output/", where <pk> is the PK of a Build.
303 The Build object is provided to the serializer context.
304 """
306 class Meta:
307 """Serializer metaclass."""
309 fields = [
310 'quantity',
311 'batch_code',
312 'serial_numbers',
313 'location',
314 'auto_allocate',
315 ]
317 quantity = serializers.DecimalField(
318 max_digits=15,
319 decimal_places=5,
320 min_value=Decimal(0),
321 required=True,
322 label=_('Quantity'),
323 help_text=_('Enter quantity for build output'),
324 )
326 def get_build(self):
327 """Return the Build instance associated with this serializer."""
328 return self.context['build']
330 def get_part(self):
331 """Return the Part instance associated with the build."""
332 return self.get_build().part
334 def validate_quantity(self, quantity):
335 """Validate the provided quantity field."""
336 if quantity <= 0: 336 ↛ 339line 336 didn't jump to line 339 because the condition on line 336 was always true
337 raise ValidationError(_('Quantity must be greater than zero'))
339 part = self.get_part()
341 if int(quantity) != quantity:
342 # Quantity must be an integer value if the part being built is trackable
343 if part.trackable:
344 raise ValidationError(
345 _('Integer quantity required for trackable parts')
346 )
348 if part.has_trackable_parts:
349 raise ValidationError(
350 _(
351 'Integer quantity required, as the bill of materials contains trackable parts'
352 )
353 )
355 return quantity
357 batch_code = serializers.CharField(
358 required=False,
359 allow_blank=True,
360 default=generate_batch_code,
361 label=_('Batch Code'),
362 help_text=_('Batch code for this build output'),
363 )
365 serial_numbers = serializers.CharField(
366 allow_blank=True,
367 required=False,
368 label=_('Serial Numbers'),
369 help_text=_('Enter serial numbers for build outputs'),
370 )
372 location = serializers.PrimaryKeyRelatedField(
373 queryset=StockLocation.objects.all(),
374 label=_('Location'),
375 help_text=_('Stock location for build output'),
376 required=False,
377 allow_null=True,
378 )
380 def validate_serial_numbers(self, serial_numbers):
381 """Clean the provided serial number string."""
382 serial_numbers = serial_numbers.strip()
384 return serial_numbers
386 auto_allocate = serializers.BooleanField(
387 required=False,
388 default=False,
389 allow_null=True,
390 label=_('Auto Allocate Serial Numbers'),
391 help_text=_(
392 'Automatically allocate required items with matching serial numbers'
393 ),
394 )
396 def validate(self, data):
397 """Perform form validation."""
398 part = self.get_part()
400 # Cache a list of serial numbers (to be used in the "save" method)
401 self.serials = None
403 quantity = data['quantity']
404 serial_numbers = data.get('serial_numbers', '')
406 if part.trackable and not serial_numbers:
407 raise ValidationError({
408 'serial_numbers': _(
409 'Serial numbers must be provided for trackable parts'
410 )
411 })
413 if serial_numbers:
414 try:
415 self.serials = InvenTree.helpers.extract_serial_numbers(
416 serial_numbers, quantity, part.get_latest_serial_number(), part=part
417 )
418 except DjangoValidationError as e:
419 raise ValidationError({'serial_numbers': e.messages})
421 # Check for conflicting serial numbers
422 existing = part.find_conflicting_serial_numbers(self.serials)
424 if len(existing) > 0:
425 msg = _('The following serial numbers already exist or are invalid')
426 msg += ' : '
427 msg += ','.join([str(e) for e in existing])
429 raise ValidationError({'serial_numbers': msg})
431 return data
433 def save(self):
434 """Generate the new build output(s)."""
435 data = self.validated_data
437 request = self.context.get('request')
438 build = self.get_build()
440 return build.create_build_output(
441 data['quantity'],
442 serials=self.serials,
443 batch=data.get('batch_code', ''),
444 location=data.get('location', None),
445 auto_allocate=data.get('auto_allocate', False),
446 user=request.user if request else None,
447 )
450class BuildOutputDeleteSerializer(serializers.Serializer):
451 """DRF serializer for deleting (cancelling) one or more build outputs."""
453 class Meta:
454 """Serializer metaclass."""
456 fields = ['outputs']
458 outputs = BuildOutputSerializer(many=True, required=True)
460 def validate(self, data):
461 """Perform data validation for this serializer."""
462 data = super().validate(data)
464 outputs = data.get('outputs', [])
466 if len(outputs) == 0:
467 raise ValidationError(_('A list of build outputs must be provided'))
469 return data
472class BuildOutputScrapSerializer(serializers.Serializer):
473 """Scrapping one or more build outputs."""
475 class Meta:
476 """Serializer metaclass."""
478 fields = ['outputs', 'location', 'notes']
480 outputs = BuildOutputQuantitySerializer(many=True, required=True)
482 location = serializers.PrimaryKeyRelatedField(
483 queryset=StockLocation.objects.all(),
484 many=False,
485 allow_null=False,
486 required=True,
487 label=_('Location'),
488 help_text=_('Stock location for scrapped outputs'),
489 )
491 discard_allocations = serializers.BooleanField(
492 required=False,
493 default=False,
494 label=_('Discard Allocations'),
495 help_text=_('Discard any stock allocations for scrapped outputs'),
496 )
498 notes = serializers.CharField(
499 label=_('Notes'),
500 help_text=_('Reason for scrapping build output(s)'),
501 required=True,
502 allow_blank=False,
503 )
505 def validate(self, data):
506 """Perform validation on the serializer data."""
507 super().validate(data)
508 outputs = data.get('outputs', [])
510 if len(outputs) == 0:
511 raise ValidationError(_('A list of build outputs must be provided'))
513 return data
516class BuildOutputCompleteSerializer(serializers.Serializer):
517 """DRF serializer for completing one or more build outputs."""
519 class Meta:
520 """Serializer metaclass."""
522 fields = [
523 'outputs',
524 'location',
525 'status_custom_key',
526 'accept_incomplete_allocation',
527 'notes',
528 ]
530 outputs = BuildOutputQuantitySerializer(many=True, required=True)
532 location = serializers.PrimaryKeyRelatedField(
533 queryset=StockLocation.objects.all(),
534 required=True,
535 many=False,
536 label=_('Location'),
537 help_text=_('Location for completed build outputs'),
538 )
540 status_custom_key = StockStatusCustomSerializer(default=StockStatus.OK.value)
542 accept_incomplete_allocation = serializers.BooleanField(
543 default=False,
544 required=False,
545 label=_('Accept Incomplete Allocation'),
546 help_text=_('Complete outputs if stock has not been fully allocated'),
547 )
549 notes = serializers.CharField(label=_('Notes'), required=False, allow_blank=True)
551 def validate(self, data):
552 """Perform data validation for this serializer."""
553 super().validate(data)
555 outputs = data.get('outputs', [])
557 if common.settings.prevent_build_output_complete_on_incompleted_tests():
558 errors = []
559 for output in outputs:
560 stock_item = output['output']
561 if (
562 stock_item.hasRequiredTests()
563 and not stock_item.passedAllRequiredTests()
564 ):
565 serial = stock_item.serial
567 if serial:
568 errors.append(
569 _(
570 f'Build output {serial} has not passed all required tests'
571 )
572 )
573 else:
574 errors.append(
575 _('Build output has not passed all required tests')
576 )
578 if errors:
579 raise ValidationError(errors)
581 if len(outputs) == 0:
582 raise ValidationError(_('A list of build outputs must be provided'))
584 return data
587class BuildIssueSerializer(serializers.Serializer):
588 """DRF serializer for issuing a build order."""
590 class Meta:
591 """Serializer metaclass."""
593 fields = []
595 def save(self):
596 """Issue the specified build order."""
597 build = self.context['build']
598 build.issue_build()
601class BuildHoldSerializer(serializers.Serializer):
602 """DRF serializer for placing a BuildOrder on hold."""
604 class Meta:
605 """Serializer metaclass."""
607 fields = []
609 def save(self):
610 """Place the specified build on hold."""
611 build = self.context['build']
613 build.hold_build()
616class BuildCancelSerializer(serializers.Serializer):
617 """Cancel an active BuildOrder."""
619 class Meta:
620 """Serializer metaclass."""
622 fields = ['remove_allocated_stock', 'remove_incomplete_outputs']
624 def get_context_data(self):
625 """Retrieve extra context data from this serializer."""
626 build = self.context['build']
628 return {
629 'has_allocated_stock': build.is_partially_allocated(),
630 'incomplete_outputs': build.incomplete_count,
631 'completed_outputs': build.complete_count,
632 }
634 remove_allocated_stock = serializers.BooleanField(
635 label=_('Consume Allocated Stock'),
636 help_text=_('Consume any stock which has already been allocated to this build'),
637 required=False,
638 default=False,
639 )
641 remove_incomplete_outputs = serializers.BooleanField(
642 label=_('Remove Incomplete Outputs'),
643 help_text=_('Delete any build outputs which have not been completed'),
644 required=False,
645 default=False,
646 )
648 def save(self):
649 """Cancel the specified build."""
650 build = self.context['build']
651 request = self.context.get('request')
653 data = self.validated_data
655 build.cancel_build(
656 request.user if request else None,
657 remove_allocated_stock=data.get('remove_allocated_stock', False),
658 remove_incomplete_outputs=data.get('remove_incomplete_outputs', False),
659 )
662class OverallocationChoice:
663 """Utility class to contain options for handling over allocated stock items."""
665 REJECT = 'reject'
666 ACCEPT = 'accept'
667 TRIM = 'trim'
669 OPTIONS = {
670 REJECT: _('Not permitted'),
671 ACCEPT: _('Accept as consumed by this build order'),
672 TRIM: _('Deallocate before completing this build order'),
673 }
676class BuildCompleteSerializer(serializers.Serializer):
677 """DRF serializer for marking a BuildOrder as complete."""
679 class Meta:
680 """Serializer metaclass."""
682 fields = ['accept_overallocated', 'accept_unallocated', 'accept_incomplete']
684 def get_context_data(self):
685 """Retrieve extra context data for this serializer.
687 This is so we can determine (at run time) whether the build is ready to be completed.
688 """
689 build = self.context['build']
691 return {
692 'overallocated': build.is_overallocated(),
693 'allocated': build.are_untracked_parts_allocated,
694 'remaining': build.remaining,
695 'incomplete': build.incomplete_count,
696 }
698 accept_overallocated = serializers.ChoiceField(
699 label=_('Overallocated Stock'),
700 choices=list(OverallocationChoice.OPTIONS.items()),
701 help_text=_(
702 'How do you want to handle extra stock items assigned to the build order'
703 ),
704 required=False,
705 default=OverallocationChoice.REJECT,
706 )
708 def validate_accept_overallocated(self, value):
709 """Check if the 'accept_overallocated' field is required."""
710 build = self.context['build']
712 if build.is_overallocated() and value == OverallocationChoice.REJECT:
713 raise ValidationError(_('Some stock items have been overallocated'))
715 return value
717 accept_unallocated = serializers.BooleanField(
718 label=_('Accept Unallocated'),
719 help_text=_(
720 'Accept that stock items have not been fully allocated to this build order'
721 ),
722 required=False,
723 default=False,
724 )
726 def validate_accept_unallocated(self, value):
727 """Check if the 'accept_unallocated' field is required."""
728 build = self.context['build']
730 if not build.are_untracked_parts_allocated and not value:
731 raise ValidationError(_('Required stock has not been fully allocated'))
733 return value
735 accept_incomplete = serializers.BooleanField(
736 label=_('Accept Incomplete'),
737 help_text=_(
738 'Accept that the required number of build outputs have not been completed'
739 ),
740 required=False,
741 default=False,
742 )
744 def validate_accept_incomplete(self, value):
745 """Check if the 'accept_incomplete' field is required."""
746 build = self.context['build']
748 if build.remaining > 0 and not value:
749 raise ValidationError(_('Required build quantity has not been completed'))
751 return value
753 def validate(self, data):
754 """Perform validation of this serializer prior to saving."""
755 build = self.context['build']
757 if (
758 get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS')
759 and build.has_open_child_builds
760 ):
761 raise ValidationError(_('Build order has open child build orders'))
763 if build.status != BuildStatus.PRODUCTION.value:
764 raise ValidationError(_('Build order must be in production state'))
766 if build.incomplete_count > 0:
767 raise ValidationError(_('Build order has incomplete outputs'))
769 return data
771 def save(self):
772 """Complete the specified build output."""
773 request = self.context.get('request')
774 build = self.context['build']
776 data = self.validated_data
778 build.complete_build(
779 request.user if request else None,
780 trim_allocated_stock=data.get(
781 'accept_overallocated', OverallocationChoice.REJECT
782 )
783 == OverallocationChoice.TRIM,
784 )
787class BuildUnallocationSerializer(serializers.Serializer):
788 """DRF serializer for unallocating stock from a BuildOrder.
790 Allocated stock can be unallocated with a number of filters:
792 - output: Filter against a particular build output (blank = untracked stock)
793 - bom_item: Filter against a particular BOM line item
794 """
796 class Meta:
797 """Serializer metaclass."""
799 fields = ['build_line', 'output']
801 build_line = serializers.PrimaryKeyRelatedField(
802 queryset=BuildLine.objects.all(),
803 many=False,
804 allow_null=True,
805 required=False,
806 label=_('Build Line'),
807 )
809 output = serializers.PrimaryKeyRelatedField(
810 queryset=StockItem.objects.filter(is_building=True),
811 many=False,
812 allow_null=True,
813 required=False,
814 label=_('Build output'),
815 )
817 def validate_output(self, stock_item):
818 """Validation for the output StockItem instance. Stock item must point to the same build order!"""
819 build = self.context['build']
821 if stock_item and stock_item.build != build:
822 raise ValidationError(_('Build output must point to the same build'))
824 return stock_item
826 def save(self):
827 """Save the serializer data.
829 This performs the actual unallocation against the build order
830 """
831 build = self.context['build']
833 data = self.validated_data
835 build.deallocate_stock(
836 build_line=data.get('build_line', None), output=data.get('output', None)
837 )
840class BuildAllocationItemSerializer(serializers.Serializer):
841 """A serializer for allocating a single stock item against a build order."""
843 class Meta:
844 """Serializer metaclass."""
846 fields = ['build_item', 'stock_item', 'quantity', 'output']
848 build_line = serializers.PrimaryKeyRelatedField(
849 queryset=BuildLine.objects.all(),
850 many=False,
851 allow_null=False,
852 required=True,
853 label=_('Build Line Item'),
854 )
856 def validate_build_line(self, build_line):
857 """Check if the parts match."""
858 build = self.context['build']
860 # BomItem should point to the same 'part' as the parent build
861 if build.part != build_line.bom_item.part:
862 # If not, it may be marked as "inherited" from a parent part
863 if (
864 build_line.bom_item.inherited
865 and build.part
866 in build_line.bom_item.part.get_descendants(include_self=False)
867 ):
868 pass
869 else:
870 raise ValidationError(
871 _('bom_item.part must point to the same part as the build order')
872 )
874 return build_line
876 stock_item = serializers.PrimaryKeyRelatedField(
877 queryset=StockItem.objects.all(),
878 many=False,
879 allow_null=False,
880 required=True,
881 label=_('Stock Item'),
882 )
884 def validate_stock_item(self, stock_item):
885 """Perform validation of the stock_item field."""
886 if not stock_item.in_stock:
887 raise ValidationError(_('Item must be in stock'))
889 return stock_item
891 quantity = serializers.DecimalField(
892 max_digits=15, decimal_places=5, min_value=Decimal(0), required=True
893 )
895 def validate_quantity(self, quantity):
896 """Perform validation of the 'quantity' field."""
897 if quantity <= 0:
898 raise ValidationError(_('Quantity must be greater than zero'))
900 return quantity
902 output = serializers.PrimaryKeyRelatedField(
903 queryset=StockItem.objects.filter(is_building=True),
904 many=False,
905 allow_null=True,
906 required=False,
907 label=_('Build Output'),
908 )
910 def validate(self, data):
911 """Perform data validation for this item."""
912 super().validate(data)
914 build_line = data['build_line']
915 stock_item = data['stock_item']
916 quantity = data['quantity']
917 output = data.get('output', None)
919 # build = self.context['build']
921 # TODO: Check that the "stock item" is valid for the referenced "sub_part"
922 # Note: Because of allow_variants options, it may not be a direct match!
924 # Check that the quantity does not exceed the available amount from the stock item
925 q = stock_item.unallocated_quantity()
927 if quantity > q:
928 q = InvenTree.helpers.clean_decimal(q)
930 raise ValidationError({'quantity': _(f'Available quantity ({q}) exceeded')})
932 # Output *must* be set for trackable parts
933 if output is None and build_line.bom_item.sub_part.trackable:
934 raise ValidationError({
935 'output': _(
936 'Build output must be specified for allocation of tracked parts'
937 )
938 })
940 # Output *cannot* be set for un-tracked parts
941 if output is not None and not build_line.bom_item.sub_part.trackable:
942 raise ValidationError({
943 'output': _(
944 'Build output cannot be specified for allocation of untracked parts'
945 )
946 })
948 return data
951class BuildAllocationSerializer(serializers.Serializer):
952 """Serializer for allocating stock items against a build order."""
954 class Meta:
955 """Serializer metaclass."""
957 fields = ['items']
959 items = BuildAllocationItemSerializer(many=True)
961 def validate(self, data):
962 """Validation."""
963 data = super().validate(data)
965 items = data.get('items', [])
967 if len(items) == 0: 967 ↛ 970line 967 didn't jump to line 970 because the condition on line 967 was always true
968 raise ValidationError(_('Allocation items must be provided'))
970 return data
972 def save(self):
973 """Perform the allocation."""
974 data = self.validated_data
976 items = data.get('items', [])
978 with transaction.atomic():
979 for item in items:
980 build_line = item['build_line']
981 stock_item = item['stock_item']
982 quantity = item['quantity']
983 output = item.get('output', None)
985 # Ignore allocation for consumable BOM items
986 if build_line.bom_item.consumable:
987 continue
989 params = {
990 'build_line': build_line,
991 'stock_item': stock_item,
992 'install_into': output,
993 }
995 try:
996 # Lock the row, so concurrent allocations cannot both read
997 # the same starting quantity (lost update)
998 if build_item := (
999 BuildItem.objects.select_for_update().filter(**params).first()
1000 ):
1001 # Find an existing BuildItem for this stock item
1002 # If it exists, increase the quantity
1003 build_item.quantity += quantity
1004 build_item.save()
1005 else:
1006 # Create a new BuildItem to allocate stock
1007 build_item = BuildItem.objects.create(
1008 quantity=quantity, **params
1009 )
1010 except (ValidationError, DjangoValidationError) as exc:
1011 # Catch model errors and re-throw as DRF errors
1012 raise ValidationError(detail=serializers.as_serializer_error(exc))
1015class BuildAutoAllocationSerializer(serializers.Serializer):
1016 """DRF serializer for auto allocating stock items against a build order."""
1018 class Meta:
1019 """Serializer metaclass."""
1021 fields = [
1022 'location',
1023 'exclude_location',
1024 'interchangeable',
1025 'substitutes',
1026 'optional_items',
1027 ]
1029 location = serializers.PrimaryKeyRelatedField(
1030 queryset=StockLocation.objects.all(),
1031 many=False,
1032 allow_null=True,
1033 required=False,
1034 label=_('Source Location'),
1035 help_text=_(
1036 'Stock location where parts are to be sourced (leave blank to take from any location)'
1037 ),
1038 )
1040 exclude_location = serializers.PrimaryKeyRelatedField(
1041 queryset=StockLocation.objects.all(),
1042 many=False,
1043 allow_null=True,
1044 required=False,
1045 label=_('Exclude Location'),
1046 help_text=_('Exclude stock items from this selected location'),
1047 )
1049 interchangeable = serializers.BooleanField(
1050 default=False,
1051 label=_('Interchangeable Stock'),
1052 help_text=_('Stock items in multiple locations can be used interchangeably'),
1053 )
1055 substitutes = serializers.BooleanField(
1056 default=True,
1057 label=_('Substitute Stock'),
1058 help_text=_('Allow allocation of substitute parts'),
1059 )
1061 optional_items = serializers.BooleanField(
1062 default=False,
1063 label=_('Optional Items'),
1064 help_text=_('Allocate optional BOM items to build order'),
1065 )
1067 item_type = serializers.ChoiceField(
1068 default=Build.BuildItemTypes.UNTRACKED,
1069 choices=[
1070 (Build.BuildItemTypes.ALL, _('All Items')),
1071 (Build.BuildItemTypes.UNTRACKED, _('Untracked Items')),
1072 (Build.BuildItemTypes.TRACKED, _('Tracked Items')),
1073 ],
1074 label=_('Item Type'),
1075 help_text=_('Select item type to auto-allocate'),
1076 )
1078 stock_sort_by = serializers.ChoiceField(
1079 default=stock_models.STOCK_SORT_DEFAULT,
1080 choices=stock_models.STOCK_SORT_CHOICES,
1081 label=_('Stock Priority'),
1082 help_text=_('Preferred order in which matching stock items are consumed'),
1083 )
1085 build_lines = serializers.PrimaryKeyRelatedField(
1086 queryset=BuildLine.objects.all(),
1087 many=True,
1088 required=False,
1089 default=list,
1090 label=_('Build Lines'),
1091 help_text=_(
1092 'Limit allocation to these build lines (leave blank to allocate all lines)'
1093 ),
1094 )
1097class BuildItemSerializer(
1098 FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer
1099):
1100 """Serializes a BuildItem object, which is an allocation of a stock item against a build order."""
1102 export_child_fields = [
1103 'build_detail.reference',
1104 'location_detail.name',
1105 'part_detail.name',
1106 'part_detail.description',
1107 'part_detail.IPN',
1108 'stock_item_detail.batch',
1109 'stock_item_detail.packaging',
1110 'stock_item_detail.part',
1111 'stock_item_detail.quantity',
1112 'stock_item_detail.serial',
1113 'supplier_part_detail.SKU',
1114 'supplier_part_detail.MPN',
1115 ]
1117 # These fields are only used for data export
1118 export_only_fields = ['bom_part_id', 'bom_part_name']
1120 class Meta:
1121 """Serializer metaclass."""
1123 model = BuildItem
1124 fields = [
1125 'pk',
1126 'build',
1127 'build_line',
1128 'install_into',
1129 'stock_item',
1130 'quantity',
1131 'location',
1132 # Detail fields, can be included or excluded
1133 'build_detail',
1134 'location_detail',
1135 'part_detail',
1136 'stock_item_detail',
1137 'supplier_part_detail',
1138 'install_into_detail',
1139 # The following fields are only used for data export
1140 'bom_reference',
1141 'bom_part_id',
1142 'bom_part_name',
1143 ]
1145 # Export-only fields
1146 bom_reference = serializers.CharField(
1147 source='build_line.bom_item.reference', label=_('BOM Reference'), read_only=True
1148 )
1150 # BOM Item Part ID (it may be different to the allocated part)
1151 bom_part_id = serializers.PrimaryKeyRelatedField(
1152 source='build_line.bom_item.sub_part',
1153 label=_('BOM Part ID'),
1154 many=False,
1155 read_only=True,
1156 )
1158 bom_part_name = serializers.CharField(
1159 source='build_line.bom_item.sub_part.name',
1160 label=_('BOM Part Name'),
1161 read_only=True,
1162 )
1164 # Annotated fields
1165 build = serializers.PrimaryKeyRelatedField(
1166 source='build_line.build', many=False, read_only=True
1167 )
1169 # Extra (optional) detail fields
1170 part_detail = OptionalField(
1171 serializer_class=part_serializers.PartBriefSerializer,
1172 serializer_kwargs={
1173 'label': _('Part'),
1174 'source': 'stock_item.part',
1175 'many': False,
1176 'read_only': True,
1177 'allow_null': True,
1178 'pricing': False,
1179 },
1180 default_include=True,
1181 prefetch_fields=['stock_item__part'],
1182 )
1184 stock_item_detail = OptionalField(
1185 serializer_class=StockItemSerializer,
1186 serializer_kwargs={
1187 'source': 'stock_item',
1188 'read_only': True,
1189 'allow_null': True,
1190 'label': _('Stock Item'),
1191 'part_detail': False,
1192 'location_detail': False,
1193 'supplier_part_detail': False,
1194 'path_detail': False,
1195 },
1196 default_include=True,
1197 filter_name='stock_detail',
1198 prefetch_fields=[
1199 'stock_item',
1200 'stock_item__part',
1201 'stock_item__supplier_part',
1202 'stock_item__supplier_part__manufacturer_part',
1203 ],
1204 )
1206 install_into_detail = OptionalField(
1207 serializer_class=StockItemSerializer,
1208 serializer_kwargs={
1209 'source': 'install_into',
1210 'read_only': True,
1211 'allow_null': True,
1212 'label': _('Install Into'),
1213 'part_detail': False,
1214 'location_detail': False,
1215 'supplier_part_detail': False,
1216 'path_detail': False,
1217 },
1218 default_include=False,
1219 prefetch_fields=['install_into', 'install_into__part'],
1220 )
1222 location = serializers.PrimaryKeyRelatedField(
1223 label=_('Location'), source='stock_item.location', many=False, read_only=True
1224 )
1226 location_detail = OptionalField(
1227 serializer_class=LocationBriefSerializer,
1228 serializer_kwargs={
1229 'label': _('Location'),
1230 'source': 'stock_item.location',
1231 'read_only': True,
1232 'allow_null': True,
1233 },
1234 default_include=True,
1235 prefetch_fields=['stock_item__location'],
1236 )
1238 build_detail = OptionalField(
1239 serializer_class=BuildSerializer,
1240 serializer_kwargs={
1241 'label': _('Build'),
1242 'source': 'build_line.build',
1243 'many': False,
1244 'read_only': True,
1245 'allow_null': True,
1246 },
1247 default_include=True,
1248 prefetch_fields=[
1249 'build_line__build',
1250 'build_line__build__part',
1251 'build_line__build__responsible',
1252 'build_line__build__issued_by',
1253 'build_line__build__project_code',
1254 'build_line__build__part__pricing_data',
1255 ],
1256 )
1258 supplier_part_detail = OptionalField(
1259 serializer_class=company.serializers.SupplierPartSerializer,
1260 serializer_kwargs={
1261 'label': _('Supplier Part'),
1262 'source': 'stock_item.supplier_part',
1263 'many': False,
1264 'read_only': True,
1265 'allow_null': True,
1266 'brief': True,
1267 },
1268 default_include=False,
1269 prefetch_fields=[
1270 'stock_item__supplier_part',
1271 'stock_item__supplier_part__supplier',
1272 'stock_item__supplier_part__manufacturer_part',
1273 'stock_item__supplier_part__manufacturer_part__manufacturer',
1274 ],
1275 )
1277 quantity = InvenTreeDecimalField(label=_('Allocated Quantity'))
1280class BuildLineSerializer(
1281 FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer
1282):
1283 """Serializer for a BuildItem object."""
1285 export_exclude_fields = ['allocations']
1287 export_child_fields = [
1288 'build_detail.reference',
1289 'part_detail.name',
1290 'part_detail.description',
1291 'part_detail.IPN',
1292 'part_detail.category',
1293 'bom_item_detail.reference',
1294 ]
1296 export_only_fields = ['part_category_name']
1298 class Meta:
1299 """Serializer metaclass."""
1301 model = BuildLine
1302 fields = [
1303 'pk',
1304 'build',
1305 'bom_item',
1306 'quantity',
1307 'consumed',
1308 'allocations',
1309 'part',
1310 # Build detail fields
1311 'build_reference',
1312 # BOM item detail fields
1313 'reference',
1314 'consumable',
1315 'optional',
1316 'testable',
1317 'trackable',
1318 'inherited',
1319 'allow_variants',
1320 # Annotated fields
1321 'allocated',
1322 'in_production',
1323 'scheduled_to_build',
1324 'on_order',
1325 'available_stock',
1326 'available_substitute_stock',
1327 'available_variant_stock',
1328 'external_stock',
1329 # Related fields
1330 'allocations',
1331 # Extra fields only for data export
1332 'part_category_name',
1333 # Extra detail (related field) serializers
1334 'bom_item_detail',
1335 'assembly_detail',
1336 'part_detail',
1337 'category_detail',
1338 'build_detail',
1339 ]
1340 read_only_fields = ['build', 'bom_item', 'allocations']
1342 # Build info fields
1343 build_reference = serializers.CharField(
1344 source='build.reference', label=_('Build Reference'), read_only=True
1345 )
1347 # Part info fields
1348 part = serializers.PrimaryKeyRelatedField(
1349 source='bom_item.sub_part', label=_('Part'), many=False, read_only=True
1350 )
1352 part_category_name = serializers.CharField(
1353 source='bom_item.sub_part.category.name',
1354 label=_('Part Category Name'),
1355 read_only=True,
1356 )
1358 allocations = OptionalField(
1359 serializer_class=BuildItemSerializer,
1360 serializer_kwargs={
1361 'many': True,
1362 'read_only': True,
1363 'allow_null': True,
1364 'build_detail': False,
1365 },
1366 default_include=True,
1367 prefetch_fields=[
1368 'allocations',
1369 'allocations__stock_item',
1370 'allocations__stock_item__part',
1371 'allocations__stock_item__part__pricing_data',
1372 'allocations__stock_item__supplier_part',
1373 'allocations__stock_item__supplier_part__manufacturer_part',
1374 'allocations__stock_item__location',
1375 ],
1376 )
1378 # BOM item info fields
1379 reference = serializers.CharField(
1380 source='bom_item.reference', label=_('Reference'), read_only=True
1381 )
1382 consumable = serializers.BooleanField(
1383 source='bom_item.consumable', label=_('Consumable'), read_only=True
1384 )
1385 optional = serializers.BooleanField(
1386 source='bom_item.optional', label=_('Optional'), read_only=True
1387 )
1388 testable = serializers.BooleanField(
1389 source='bom_item.sub_part.testable', label=_('Testable'), read_only=True
1390 )
1391 trackable = serializers.BooleanField(
1392 source='bom_item.sub_part.trackable', label=_('Trackable'), read_only=True
1393 )
1394 inherited = serializers.BooleanField(
1395 source='bom_item.inherited', label=_('Inherited'), read_only=True
1396 )
1397 allow_variants = serializers.BooleanField(
1398 source='bom_item.allow_variants', label=_('Allow Variants'), read_only=True
1399 )
1401 quantity = serializers.FloatField(label=_('Quantity'))
1402 consumed = serializers.FloatField(label=_('Consumed'))
1404 bom_item = serializers.PrimaryKeyRelatedField(label=_('BOM Item'), read_only=True)
1406 # Foreign key fields
1407 bom_item_detail = OptionalField(
1408 serializer_class=part_serializers.BomItemSerializer,
1409 serializer_kwargs={
1410 'label': _('BOM Item'),
1411 'source': 'bom_item',
1412 'many': False,
1413 'read_only': True,
1414 'allow_null': True,
1415 'pricing': False,
1416 'substitutes': False,
1417 'sub_part_detail': False,
1418 'part_detail': False,
1419 'can_build': False,
1420 },
1421 default_include=False,
1422 prefetch_fields=['bom_item'],
1423 )
1425 assembly_detail = OptionalField(
1426 serializer_class=part_serializers.PartBriefSerializer,
1427 serializer_kwargs={
1428 'label': _('Assembly'),
1429 'source': 'bom_item.part',
1430 'many': False,
1431 'read_only': True,
1432 'allow_null': True,
1433 'pricing': False,
1434 },
1435 default_include=False,
1436 prefetch_fields=['bom_item__part', 'bom_item__part__pricing_data'],
1437 )
1439 part_detail = OptionalField(
1440 serializer_class=part_serializers.PartBriefSerializer,
1441 serializer_kwargs={
1442 'label': _('Part'),
1443 'source': 'bom_item.sub_part',
1444 'many': False,
1445 'read_only': True,
1446 'allow_null': True,
1447 'pricing': False,
1448 },
1449 default_include=False,
1450 prefetch_fields=['bom_item__sub_part', 'bom_item__sub_part__pricing_data'],
1451 )
1453 category_detail = OptionalField(
1454 serializer_class=part_serializers.CategorySerializer,
1455 serializer_kwargs={
1456 'label': _('Category'),
1457 'source': 'bom_item.sub_part.category',
1458 'many': False,
1459 'read_only': True,
1460 'allow_null': True,
1461 'path_detail': False,
1462 },
1463 default_include=False,
1464 prefetch_fields=['bom_item__sub_part__category'],
1465 )
1467 build_detail = OptionalField(
1468 serializer_class=BuildSerializer,
1469 serializer_kwargs={
1470 'label': _('Build'),
1471 'source': 'build',
1472 'many': False,
1473 'read_only': True,
1474 'allow_null': True,
1475 'part_detail': False,
1476 'user_detail': False,
1477 'project_code_detail': False,
1478 },
1479 default_include=True,
1480 )
1482 # Annotated (calculated) fields
1484 # Total quantity of allocated stock
1485 allocated = serializers.FloatField(label=_('Allocated'), read_only=True)
1487 on_order = serializers.FloatField(label=_('On Order'), read_only=True)
1488 in_production = serializers.FloatField(label=_('In Production'), read_only=True)
1489 scheduled_to_build = serializers.FloatField(
1490 label=_('Scheduled to Build'), read_only=True
1491 )
1493 external_stock = serializers.FloatField(read_only=True, label=_('External Stock'))
1494 available_stock = serializers.FloatField(read_only=True, label=_('Available Stock'))
1495 available_substitute_stock = serializers.FloatField(
1496 read_only=True, label=_('Available Substitute Stock')
1497 )
1498 available_variant_stock = serializers.FloatField(
1499 read_only=True, label=_('Available Variant Stock')
1500 )
1502 @staticmethod
1503 def annotate_queryset(queryset, build=None):
1504 """Add extra annotations to the queryset.
1506 Annotations:
1507 - allocated: Total stock quantity allocated against this build line
1508 - available: Total stock available for allocation against this build line
1509 - on_order: Total stock on order for this build line
1510 - in_production: Total stock currently in production for this build line
1511 - scheduled_to_build: Total stock scheduled to be built for this build line
1513 Arguments:
1514 queryset: The queryset to annotate
1515 build: The build order to filter against (optional)
1517 Note: If the 'build' is provided, we can use it to filter available stock, depending on the specified location for the build
1519 """
1520 queryset = queryset.select_related(
1521 'build',
1522 'build__part',
1523 'build__part__pricing_data',
1524 'bom_item',
1525 'bom_item__part',
1526 'bom_item__part__pricing_data',
1527 'bom_item__sub_part',
1528 'bom_item__sub_part__pricing_data',
1529 )
1531 # Defer expensive fields which we do not need for this serializer
1533 queryset = queryset.defer(
1534 'build__notes',
1535 'build__metadata',
1536 'bom_item__metadata',
1537 'bom_item__part__notes',
1538 'bom_item__part__metadata',
1539 'bom_item__sub_part__notes',
1540 'bom_item__sub_part__metadata',
1541 )
1543 # Annotate the "allocated" quantity
1544 queryset = queryset.annotate(
1545 allocated=Coalesce(
1546 Sum('allocations__quantity'), 0, output_field=models.DecimalField()
1547 )
1548 )
1550 ref = 'bom_item__sub_part__'
1552 stock_filter = None
1554 if build is not None and build.take_from is not None: 1554 ↛ 1555line 1554 didn't jump to line 1555 because the condition on line 1554 was never true
1555 location = build.take_from
1556 # Filter by locations below the specified location
1557 stock_filter = Q(
1558 location__tree_id=location.tree_id,
1559 location__lft__gte=location.lft,
1560 location__rght__lte=location.rght,
1561 location__level__gte=location.level,
1562 )
1563 else:
1564 location = None
1566 # Annotate the "in_production" quantity
1567 queryset = queryset.annotate(
1568 in_production=part.filters.annotate_in_production_quantity(reference=ref),
1569 scheduled_to_build=part.filters.annotate_scheduled_to_build_quantity(
1570 reference=ref
1571 ),
1572 )
1574 # Annotate the "on_order" quantity
1575 queryset = queryset.annotate(
1576 on_order=part.filters.annotate_on_order_quantity(reference=ref)
1577 )
1579 # Annotate the "available" quantity
1580 queryset = queryset.alias(
1581 total_stock=part.filters.annotate_total_stock(
1582 reference=ref, filter=stock_filter
1583 ),
1584 allocated_to_sales_orders=part.filters.annotate_sales_order_allocations(
1585 reference=ref, location=location
1586 ),
1587 allocated_to_build_orders=part.filters.annotate_build_order_allocations(
1588 reference=ref, location=location
1589 ),
1590 )
1592 # Calculate 'available_stock' based on previously annotated fields
1593 queryset = queryset.annotate(
1594 available_stock=Greatest(
1595 ExpressionWrapper(
1596 F('total_stock')
1597 - F('allocated_to_sales_orders')
1598 - F('allocated_to_build_orders'),
1599 output_field=models.DecimalField(),
1600 ),
1601 0,
1602 output_field=models.DecimalField(),
1603 )
1604 )
1606 external_stock_filter = Q(location__external=True)
1608 if stock_filter: 1608 ↛ 1609line 1608 didn't jump to line 1609 because the condition on line 1608 was never true
1609 external_stock_filter &= stock_filter
1611 # Add 'external stock' annotations
1612 queryset = queryset.annotate(
1613 external_stock=part.filters.annotate_total_stock(
1614 reference=ref, filter=external_stock_filter
1615 )
1616 )
1618 ref = 'bom_item__substitutes__part__'
1620 # Extract similar information for any 'substitute' parts
1621 queryset = queryset.alias(
1622 substitute_stock=part.filters.annotate_total_stock(
1623 reference=ref, filter=stock_filter
1624 ),
1625 substitute_build_allocations=part.filters.annotate_build_order_allocations(
1626 reference=ref
1627 ),
1628 substitute_sales_allocations=part.filters.annotate_sales_order_allocations(
1629 reference=ref
1630 ),
1631 )
1633 # Calculate 'available_substitute_stock' field
1634 queryset = queryset.annotate(
1635 available_substitute_stock=Greatest(
1636 ExpressionWrapper(
1637 F('substitute_stock')
1638 - F('substitute_build_allocations')
1639 - F('substitute_sales_allocations'),
1640 output_field=models.DecimalField(),
1641 ),
1642 0,
1643 output_field=models.DecimalField(),
1644 )
1645 )
1647 # Annotate the queryset with 'available variant stock' information
1648 variant_stock_query = part.filters.variant_stock_query(
1649 reference='bom_item__sub_part__', filter=stock_filter
1650 )
1652 queryset = queryset.alias(
1653 variant_stock_total=part.filters.annotate_variant_quantity(
1654 variant_stock_query, reference='quantity'
1655 ),
1656 variant_bo_allocations=part.filters.annotate_variant_quantity(
1657 variant_stock_query, reference='sales_order_allocations__quantity'
1658 ),
1659 variant_so_allocations=part.filters.annotate_variant_quantity(
1660 variant_stock_query, reference='allocations__quantity'
1661 ),
1662 )
1664 queryset = queryset.annotate(
1665 available_variant_stock=Greatest(
1666 ExpressionWrapper(
1667 F('variant_stock_total')
1668 - F('variant_bo_allocations')
1669 - F('variant_so_allocations'),
1670 output_field=FloatField(),
1671 ),
1672 0,
1673 output_field=FloatField(),
1674 )
1675 )
1677 return queryset
1680class BuildConsumeAllocationSerializer(serializers.Serializer):
1681 """Serializer for an individual BuildItem to be consumed against a BuildOrder."""
1683 class Meta:
1684 """Serializer metaclass."""
1686 fields = ['build_item', 'quantity']
1688 build_item = serializers.PrimaryKeyRelatedField(
1689 queryset=BuildItem.objects.all(), many=False, allow_null=False, required=True
1690 )
1692 quantity = serializers.DecimalField(
1693 max_digits=15, decimal_places=5, min_value=Decimal(0), required=True
1694 )
1696 def validate_quantity(self, quantity):
1697 """Perform validation on the 'quantity' field."""
1698 if quantity <= 0:
1699 raise ValidationError(_('Quantity must be greater than zero'))
1701 return quantity
1703 def validate(self, data):
1704 """Validate the serializer data."""
1705 data = super().validate(data)
1707 build_item = data['build_item']
1708 quantity = data['quantity']
1710 if quantity > build_item.quantity:
1711 raise ValidationError({
1712 'quantity': _('Consumed quantity exceeds allocated quantity')
1713 })
1715 return data
1718class BuildConsumeLineItemSerializer(serializers.Serializer):
1719 """Serializer for an individual BuildLine to be consumed against a BuildOrder."""
1721 class Meta:
1722 """Serializer metaclass."""
1724 fields = ['build_line']
1726 build_line = serializers.PrimaryKeyRelatedField(
1727 queryset=BuildLine.objects.all(), many=False, allow_null=False, required=True
1728 )
1731class BuildConsumeSerializer(serializers.Serializer):
1732 """Serializer for consuming allocations against a BuildOrder.
1734 - Consumes allocated stock items, increasing the 'consumed' field for each BuildLine.
1735 - Stock can be consumed by passing either a list of BuildItem objects, or a list of BuildLine objects.
1736 """
1738 class Meta:
1739 """Serializer metaclass."""
1741 fields = ['items', 'lines', 'notes']
1743 items = BuildConsumeAllocationSerializer(many=True, required=False)
1745 lines = BuildConsumeLineItemSerializer(many=True, required=False)
1747 notes = serializers.CharField(
1748 label=_('Notes'),
1749 help_text=_('Optional notes for the stock consumption'),
1750 required=False,
1751 allow_blank=True,
1752 )
1754 def validate_items(self, items):
1755 """Validate the BuildItem list passed to the serializer."""
1756 build_order = self.context['build']
1758 seen = set()
1760 for item in items:
1761 build_item = item['build_item']
1763 # BuildItem must point to the correct build order
1764 if build_item.build != build_order:
1765 raise ValidationError(
1766 _('Build item must point to the correct build order')
1767 )
1769 # Prevent duplicate item allocation
1770 if build_item.pk in seen:
1771 raise ValidationError(_('Duplicate build item allocation'))
1773 seen.add(build_item.pk)
1775 return items
1777 def validate_lines(self, lines):
1778 """Validate the BuildLine list passed to the serializer."""
1779 build_order = self.context['build']
1781 seen = set()
1783 for line in lines:
1784 build_line = line['build_line']
1786 # BuildLine must point to the correct build order
1787 if build_line.build != build_order:
1788 raise ValidationError(
1789 _('Build line must point to the correct build order')
1790 )
1792 # Prevent duplicate line allocation
1793 if build_line.pk in seen:
1794 raise ValidationError(_('Duplicate build line allocation'))
1796 seen.add(build_line.pk)
1798 return lines
1800 def validate(self, data):
1801 """Validate the serializer data."""
1802 items = data.get('items', [])
1803 lines = data.get('lines', [])
1805 if len(items) == 0 and len(lines) == 0:
1806 raise ValidationError(_('At least one item or line must be provided'))
1808 return data