Coverage for src/backend/InvenTree/build/models.py: 22%
739 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"""Build database model definitions."""
3import decimal
4from typing import Optional, TypedDict
6from django.contrib.auth.models import User
7from django.core.exceptions import ValidationError
8from django.core.validators import MinValueValidator
9from django.db import models, transaction
10from django.db.models import F, Q, QuerySet, Sum
11from django.db.models.functions import Coalesce
12from django.db.models.signals import post_save
13from django.dispatch.dispatcher import receiver
14from django.urls import reverse
15from django.utils.translation import gettext_lazy as _
17import structlog
18from mptt.models import TreeForeignKey
19from rest_framework import serializers
21import generic.states
22import InvenTree.fields
23import InvenTree.helpers
24import InvenTree.helpers_model
25import InvenTree.models
26import InvenTree.ready
27import InvenTree.tasks
28import part.models
29import report.mixins
30import stock.models
31import users.models
32from build.events import BuildEvents
33from build.filters import annotate_allocated_quantity, annotate_required_quantity
34from build.status_codes import BuildStatus, BuildStatusGroups
35from build.validators import (
36 generate_next_build_reference,
37 validate_build_order_reference,
38)
39from common.models import ProjectCode
40from common.settings import (
41 get_global_setting,
42 prevent_build_output_complete_on_incompleted_tests,
43)
44from generic.enums import StringEnum
45from generic.states import StateTransitionMixin, StatusCodeMixin
46from plugin.events import trigger_event
47from stock.status_codes import StockHistoryCode, StockStatus
49logger = structlog.get_logger('inventree')
52class BuildReportContext(report.mixins.BaseReportContext, TypedDict):
53 """Context for the Build model.
55 Attributes:
56 bom_items: Query set of all BuildItem objects associated with the BuildOrder
57 build: The BuildOrder instance itself
58 build_outputs: Query set of all BuildItem objects associated with the BuildOrder
59 line_items: Query set of all build line items associated with the BuildOrder
60 part: The Part object which is being assembled in the build order
61 quantity: The total quantity of the part being assembled
62 reference: The reference field of the BuildOrder
63 title: The title field of the BuildOrder
64 """
66 bom_items: report.mixins.QuerySet[part.models.BomItem]
67 build: 'Build'
68 build_outputs: report.mixins.QuerySet[stock.models.StockItem]
69 line_items: report.mixins.QuerySet['BuildLine']
70 part: part.models.Part
71 quantity: int
72 reference: str
73 title: str
76class Build(
77 InvenTree.models.PluginValidationMixin,
78 report.mixins.InvenTreeReportMixin,
79 InvenTree.models.InvenTreeParameterMixin,
80 InvenTree.models.InvenTreeAttachmentMixin,
81 InvenTree.models.InvenTreeBarcodeMixin,
82 InvenTree.models.InvenTreeTagsMixin,
83 InvenTree.models.InvenTreeNotesMixin,
84 InvenTree.models.ReferenceIndexingMixin,
85 StateTransitionMixin,
86 StatusCodeMixin,
87 InvenTree.models.MetadataMixin,
88 InvenTree.models.InvenTreeTree,
89):
90 """A Build object organizes the creation of new StockItem objects from other existing StockItem objects.
92 Attributes:
93 part: The part to be built (from component BOM items)
94 reference: Build order reference (required, must be unique)
95 title: Brief title describing the build (optional)
96 quantity: Number of units to be built
97 parent: Reference to a Build object for which this Build is required
98 sales_order: References to a SalesOrder object for which this Build is required (e.g. the output of this build will be used to fulfil a sales order)
99 take_from: Location to take stock from to make this build (if blank, can take from anywhere)
100 status: Build status code
101 external: Set to indicate that this build order is fulfilled externally
102 batch: Batch code transferred to build parts (optional)
103 creation_date: Date the build was created (auto)
104 target_date: Date the build will be overdue
105 completion_date: Date the build was completed (or, if incomplete, the expected date of completion)
106 link: External URL for extra information
107 notes: Text notes
108 completed_by: User that completed the build
109 issued_by: User that issued the build
110 responsible: User (or group) responsible for completing the build
111 priority: Priority of the build
112 """
114 STATUS_CLASS = BuildStatus
115 IMPORT_ID_FIELDS = ['reference']
117 class Meta:
118 """Metaclass options for the BuildOrder model."""
120 verbose_name = _('Build Order')
121 verbose_name_plural = _('Build Orders')
123 class MPTTMeta:
124 """MPTT options for the BuildOrder model."""
126 order_insertion_by = ['reference']
128 class BuildItemTypes(StringEnum):
129 """Enumeration of available item types."""
131 ALL = 'all' # All BOM items (both tracked and untracked)
132 TRACKED = 'tracked' # Tracked BOM items
133 UNTRACKED = 'untracked' # Untracked BOM items
135 @classmethod
136 def get_overdue_filter(cls):
137 """Filter for determining if a build order is overdue."""
138 return (
139 Q(status__in=BuildStatusGroups.ACTIVE_CODES)
140 & ~Q(target_date=None)
141 & Q(target_date__lte=InvenTree.helpers.current_date())
142 )
144 # Global setting for specifying reference pattern
145 REFERENCE_PATTERN_SETTING = 'BUILDORDER_REFERENCE_PATTERN'
147 @staticmethod
148 def get_api_url():
149 """Return the API URL associated with the BuildOrder model."""
150 return reverse('api-build-list')
152 def api_instance_filters(self):
153 """Returns custom API filters for the particular BuildOrder instance."""
154 return {'parent': {'exclude_tree': self.pk}}
156 @classmethod
157 def api_defaults(cls, request=None):
158 """Return default values for this model when issuing an API OPTIONS request."""
159 defaults = {'reference': generate_next_build_reference()}
161 if request and request.user: 161 ↛ 164line 161 didn't jump to line 164 because the condition on line 161 was always true
162 defaults['issued_by'] = request.user.pk
164 return defaults
166 @classmethod
167 def barcode_model_type_code(cls):
168 """Return the associated barcode model type code for this model."""
169 return 'BO'
171 def save(self, *args, **kwargs):
172 """Custom save method for the BuildOrder model."""
173 self.reference_int = self.validate_reference_field(self.reference)
175 # Check part when initially creating the build order
176 if not self.pk or self.has_field_changed('part'):
177 if get_global_setting('BUILDORDER_REQUIRE_VALID_BOM'):
178 # Check that the BOM is valid
179 if not self.part.is_bom_valid():
180 raise ValidationError({
181 'part': _('Assembly BOM has not been validated')
182 })
184 if get_global_setting('BUILDORDER_REQUIRE_ACTIVE_PART'):
185 # Check that the part is active
186 if not self.part.active:
187 raise ValidationError({
188 'part': _('Build order cannot be created for an inactive part')
189 })
191 if get_global_setting('BUILDORDER_REQUIRE_LOCKED_PART'):
192 # Check that the part is locked
193 if not self.part.locked:
194 raise ValidationError({
195 'part': _('Build order cannot be created for an unlocked part')
196 })
198 # On first save (i.e. creation), run some extra checks
199 if self.pk is None:
200 # Set the destination location (if not specified)
201 if not self.destination:
202 self.destination = self.part.get_default_location()
204 super().save(*args, **kwargs)
206 def clean(self):
207 """Validate the BuildOrder model."""
208 super().clean()
210 if self.external and not self.part.purchaseable:
211 raise ValidationError({
212 'external': _(
213 'Build orders can only be externally fulfilled for purchaseable parts'
214 )
215 })
217 if get_global_setting('BUILDORDER_REQUIRE_RESPONSIBLE'):
218 if not self.responsible:
219 raise ValidationError({
220 'responsible': _('Responsible user or group must be specified')
221 })
223 # Prevent changing target part after creation
224 if self.has_field_changed('part'):
225 raise ValidationError({'part': _('Build order part cannot be changed')})
227 # Target date should be *after* the start date
228 if self.start_date and self.target_date and self.start_date > self.target_date:
229 raise ValidationError({
230 'target_date': _('Target date must be after start date')
231 })
233 def report_context(self) -> BuildReportContext:
234 """Generate custom report context data."""
235 return {
236 'bom_items': self.part.get_bom_items(),
237 'build': self,
238 'build_outputs': self.build_outputs.all(),
239 'line_items': self.build_lines.all(),
240 'part': self.part,
241 'quantity': self.quantity,
242 'reference': self.reference,
243 'title': str(self),
244 }
246 def __str__(self):
247 """String representation of a BuildOrder."""
248 return self.reference
250 def get_absolute_url(self):
251 """Return the web URL associated with this BuildOrder."""
252 return InvenTree.helpers.pui_url(f'/manufacturing/build-order/{self.id}')
254 reference = models.CharField(
255 unique=True,
256 max_length=64,
257 blank=False,
258 help_text=_('Build Order Reference'),
259 verbose_name=_('Reference'),
260 default=generate_next_build_reference,
261 validators=[validate_build_order_reference],
262 )
264 title = models.CharField(
265 verbose_name=_('Description'),
266 blank=True,
267 max_length=100,
268 help_text=_('Brief description of the build (optional)'),
269 )
271 parent = TreeForeignKey(
272 'self',
273 on_delete=models.SET_NULL,
274 blank=True,
275 null=True,
276 related_name='children',
277 verbose_name=_('Parent Build'),
278 help_text=_('Build Order to which this build is allocated'),
279 )
281 part = models.ForeignKey(
282 'part.Part',
283 verbose_name=_('Part'),
284 on_delete=models.CASCADE,
285 related_name='builds',
286 limit_choices_to={'assembly': True},
287 help_text=_('Select part to build'),
288 )
290 sales_order = models.ForeignKey(
291 'order.SalesOrder',
292 verbose_name=_('Sales Order Reference'),
293 on_delete=models.SET_NULL,
294 related_name='builds',
295 null=True,
296 blank=True,
297 help_text=_('Sales Order to which this build is allocated'),
298 )
300 take_from = models.ForeignKey(
301 'stock.StockLocation',
302 verbose_name=_('Source Location'),
303 on_delete=models.SET_NULL,
304 related_name='sourcing_builds',
305 null=True,
306 blank=True,
307 help_text=_(
308 'Select location to take stock from for this build (leave blank to take from any stock location)'
309 ),
310 )
312 external = models.BooleanField(
313 default=False,
314 verbose_name=_('External Build'),
315 help_text=_('This build order is fulfilled externally'),
316 )
318 destination = models.ForeignKey(
319 'stock.StockLocation',
320 verbose_name=_('Destination Location'),
321 on_delete=models.SET_NULL,
322 related_name='incoming_builds',
323 null=True,
324 blank=True,
325 help_text=_('Select location where the completed items will be stored'),
326 )
328 quantity = models.PositiveIntegerField(
329 verbose_name=_('Build Quantity'),
330 default=1,
331 validators=[MinValueValidator(1)],
332 help_text=_('Number of stock items to build'),
333 )
335 completed = models.PositiveIntegerField(
336 verbose_name=_('Completed items'),
337 default=0,
338 help_text=_('Number of stock items which have been completed'),
339 )
341 status = generic.states.fields.InvenTreeCustomStatusModelField(
342 verbose_name=_('Build Status'),
343 default=BuildStatus.PENDING.value,
344 choices=BuildStatus.items(),
345 status_class=BuildStatus,
346 validators=[MinValueValidator(0)],
347 help_text=_('Build status code'),
348 )
350 @property
351 def status_text(self):
352 """Return the text representation of the status field."""
353 return BuildStatus.text(self.status)
355 batch = models.CharField(
356 verbose_name=_('Batch Code'),
357 max_length=100,
358 blank=True,
359 null=True,
360 help_text=_('Batch code for this build output'),
361 )
363 creation_date = models.DateField(
364 auto_now_add=True, editable=False, verbose_name=_('Creation Date')
365 )
367 start_date = models.DateField(
368 null=True,
369 blank=True,
370 verbose_name=_('Build start date'),
371 help_text=_('Scheduled start date for this build order'),
372 )
374 target_date = models.DateField(
375 null=True,
376 blank=True,
377 verbose_name=_('Target completion date'),
378 help_text=_(
379 'Target date for build completion. Build will be overdue after this date.'
380 ),
381 )
383 completion_date = models.DateField(
384 null=True, blank=True, verbose_name=_('Completion Date')
385 )
387 completed_by = models.ForeignKey(
388 User,
389 on_delete=models.SET_NULL,
390 blank=True,
391 null=True,
392 verbose_name=_('completed by'),
393 related_name='builds_completed',
394 )
396 issued_by = models.ForeignKey(
397 User,
398 on_delete=models.SET_NULL,
399 blank=True,
400 null=True,
401 verbose_name=_('Issued by'),
402 help_text=_('User who issued this build order'),
403 related_name='builds_issued',
404 )
406 @property
407 def created_by(self):
408 """Alias for issued_by field.
410 This is used for compatibility with the order models
411 """
412 return self.issued_by
414 responsible = models.ForeignKey(
415 users.models.Owner,
416 on_delete=models.SET_NULL,
417 blank=True,
418 null=True,
419 verbose_name=_('Responsible'),
420 help_text=_('User or group responsible for this build order'),
421 related_name='builds_responsible',
422 )
424 link = InvenTree.fields.InvenTreeURLField(
425 verbose_name=_('External Link'),
426 blank=True,
427 help_text=_('Link to external URL'),
428 max_length=2000,
429 )
431 priority = models.PositiveIntegerField(
432 verbose_name=_('Build Priority'),
433 default=0,
434 validators=[MinValueValidator(0)],
435 help_text=_('Priority of this build order'),
436 )
438 project_code = models.ForeignKey(
439 ProjectCode,
440 on_delete=models.SET_NULL,
441 blank=True,
442 null=True,
443 verbose_name=_('Project Code'),
444 help_text=_('Project code for this build order'),
445 )
447 def sub_builds(self, cascade: bool = True) -> QuerySet:
448 """Return all Build Order objects under this one."""
449 if cascade:
450 return self.get_descendants(include_self=False)
451 else:
452 return self.get_children()
454 def sub_build_count(self, cascade: bool = True) -> int:
455 """Return the number of sub builds under this one.
457 Args:
458 cascade: If True (default), include cascading builds under sub builds
459 """
460 return self.sub_builds(cascade=cascade).count()
462 @property
463 def has_open_child_builds(self) -> bool:
464 """Return True if this build order has any open child builds."""
465 return (
466 self.sub_builds().filter(status__in=BuildStatusGroups.ACTIVE_CODES).exists()
467 )
469 @property
470 def is_overdue(self) -> bool:
471 """Returns true if this build is "overdue".
473 Makes use of the OVERDUE_FILTER to avoid code duplication
475 Returns:
476 bool: Is the build overdue
477 """
478 query = Build.objects.filter(pk=self.pk)
479 query = query.filter(Build.get_overdue_filter())
481 return query.exists()
483 @property
484 def active(self) -> bool:
485 """Return True if this build is active."""
486 return self.status in BuildStatusGroups.ACTIVE_CODES
488 @property
489 def tracked_line_items(self) -> QuerySet:
490 """Returns the "trackable" BOM lines for this BuildOrder."""
491 return self.build_lines.filter(bom_item__sub_part__trackable=True)
493 def has_tracked_line_items(self) -> bool:
494 """Returns True if this BuildOrder has trackable BomItems."""
495 return self.tracked_line_items.count() > 0
497 @property
498 def untracked_line_items(self) -> QuerySet:
499 """Returns the "non trackable" BOM items for this BuildOrder."""
500 return self.build_lines.filter(bom_item__sub_part__trackable=False)
502 @property
503 def are_untracked_parts_allocated(self) -> bool:
504 """Returns True if all untracked parts are allocated for this BuildOrder."""
505 return self.is_fully_allocated(tracked=False)
507 def has_untracked_line_items(self) -> bool:
508 """Returns True if this BuildOrder has non trackable BomItems."""
509 return self.has_untracked_line_items.count() > 0
511 @property
512 def remaining(self):
513 """Return the number of outputs remaining to be completed."""
514 return max(0, self.quantity - self.completed)
516 @property
517 def output_count(self) -> int:
518 """Return the number of build outputs (StockItem) associated with this build order."""
519 return self.build_outputs.count()
521 def has_build_outputs(self) -> bool:
522 """Returns True if this build has more than zero build outputs."""
523 return self.output_count > 0
525 def get_build_outputs(self, **kwargs) -> QuerySet:
526 """Return a list of build outputs.
528 kwargs:
529 complete = (True / False) - If supplied, filter by completed status
530 in_stock = (True / False) - If supplied, filter by 'in-stock' status
531 """
532 outputs = self.build_outputs.all()
534 # Filter by 'in stock' status
535 in_stock = kwargs.get('in_stock')
537 if in_stock is not None:
538 if in_stock:
539 outputs = outputs.filter(stock.models.StockItem.IN_STOCK_FILTER)
540 else:
541 outputs = outputs.exclude(stock.models.StockItem.IN_STOCK_FILTER)
543 # Filter by 'complete' status
544 complete = kwargs.get('complete')
546 if complete is not None:
547 if complete:
548 outputs = outputs.filter(is_building=False)
549 else:
550 outputs = outputs.filter(is_building=True)
552 return outputs
554 @property
555 def complete_outputs(self) -> QuerySet:
556 """Return all the "completed" build outputs."""
557 outputs = self.get_build_outputs(complete=True)
559 return outputs
561 @property
562 def complete_count(self):
563 """Return the total quantity of completed outputs."""
564 quantity = 0
566 for output in self.complete_outputs:
567 quantity += output.quantity
569 return quantity
571 def is_partially_allocated(self) -> bool:
572 """Test is this build order has any stock allocated against it."""
573 return self.allocated_stock.count() > 0
575 @property
576 def incomplete_outputs(self) -> QuerySet:
577 """Return all the "incomplete" build outputs."""
578 outputs = self.get_build_outputs(complete=False)
580 return outputs
582 @property
583 def incomplete_count(self):
584 """Return the total number of "incomplete" outputs."""
585 quantity = 0
587 for output in self.incomplete_outputs:
588 quantity += output.quantity
590 return quantity
592 @classmethod
593 def getNextBuildNumber(cls):
594 """Try to predict the next Build Order reference."""
595 if cls.objects.count() == 0:
596 return None
598 # Extract the "most recent" build order reference
599 builds = cls.objects.exclude(reference=None)
601 if not builds.exists():
602 return None
604 build = builds.last()
605 ref = build.reference
607 if not ref:
608 return None
610 tries = set(ref)
612 new_ref = ref
614 while 1:
615 new_ref = InvenTree.helpers.increment(new_ref)
617 if new_ref in tries:
618 # We are potentially stuck in a loop - simply return the original reference
619 return ref
621 # Check if the existing build reference exists
622 if cls.objects.filter(reference=new_ref).exists():
623 tries.add(new_ref)
624 else:
625 break
627 return new_ref
629 @property
630 def can_complete(self) -> bool:
631 """Returns True if this BuildOrder is ready to be completed.
633 - Must not have any outstanding build outputs
634 - Completed count must meet the required quantity
635 - Untracked parts must be allocated
636 """
637 if (
638 get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS')
639 and self.has_open_child_builds
640 ):
641 return False
643 if self.status != BuildStatus.PRODUCTION.value:
644 return False
646 if self.incomplete_count > 0:
647 return False
649 if self.remaining > 0:
650 return False
652 return self.is_fully_allocated(tracked=False)
654 def complete_allocations(self, user) -> None:
655 """Complete all stock allocations for this build order.
657 - This function is called when a build order is completed
658 """
659 # Remove untracked allocated stock
660 self.subtract_allocated_stock(user)
662 # Ensure that there are no longer any BuildItem objects
663 # which point to this Build Order
664 self.allocated_stock.all().delete()
666 @transaction.atomic
667 def complete_build(self, user: User, trim_allocated_stock: bool = False):
668 """Mark this build as complete.
670 Arguments:
671 user: The user who is completing the build
672 trim_allocated_stock: If True, trim any allocated stock
673 """
674 return self.handle_transition(
675 self.status,
676 BuildStatus.COMPLETE.value,
677 self,
678 self._action_complete,
679 user=user,
680 trim_allocated_stock=trim_allocated_stock,
681 )
683 def _action_complete(self, *args, **kwargs):
684 """Action to be taken when a build is completed."""
685 import build.tasks
687 trim_allocated_stock = kwargs.pop('trim_allocated_stock', False)
688 user = kwargs.pop('user', None)
690 # Prevent completion if there are open child builds
691 if (
692 get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS')
693 and self.has_open_child_builds
694 ):
695 raise ValidationError(
696 _('Cannot complete build order with open child builds')
697 )
699 if self.incomplete_count > 0:
700 raise ValidationError(
701 _('Cannot complete build order with incomplete outputs')
702 )
704 # Offload background task to complete build allocations
705 InvenTree.tasks.offload_task(
706 build.tasks.complete_build,
707 self.pk,
708 user.pk if user else None,
709 trim_allocated_stock=trim_allocated_stock,
710 group='build',
711 )
713 self.completion_date = InvenTree.helpers.current_date()
714 self.completed_by = user
715 self.status = BuildStatus.COMPLETE.value
716 self.save()
718 @transaction.atomic
719 def issue_build(self):
720 """Mark the Build as IN PRODUCTION.
722 Args:
723 user: The user who is issuing the build
724 """
725 return self.handle_transition(
726 self.status, BuildStatus.PENDING.value, self, self._action_issue
727 )
729 @property
730 def can_issue(self) -> bool:
731 """Returns True if this BuildOrder can be issued."""
732 return self.status in [BuildStatus.PENDING.value, BuildStatus.ON_HOLD.value]
734 def _action_issue(self, *args, **kwargs):
735 """Perform the action to mark this order as PRODUCTION."""
736 if self.can_issue:
737 self.status = BuildStatus.PRODUCTION.value
738 self.save()
740 trigger_event(BuildEvents.ISSUED, id=self.pk)
742 from build.tasks import check_build_stock
744 # Run checks on required parts
745 InvenTree.tasks.offload_task(
746 check_build_stock, self, group='build', force_async=True
747 )
749 @transaction.atomic
750 def hold_build(self):
751 """Mark the Build as ON HOLD."""
752 return self.handle_transition(
753 self.status, BuildStatus.ON_HOLD.value, self, self._action_hold
754 )
756 @property
757 def can_hold(self) -> bool:
758 """Returns True if this BuildOrder can be placed on hold."""
759 return self.status in [BuildStatus.PENDING.value, BuildStatus.PRODUCTION.value]
761 def _action_hold(self, *args, **kwargs):
762 """Action to be taken when a build is placed on hold."""
763 if self.can_hold:
764 self.status = BuildStatus.ON_HOLD.value
765 self.save()
767 trigger_event(BuildEvents.HOLD, id=self.pk)
769 @transaction.atomic
770 def cancel_build(self, user, **kwargs):
771 """Mark the Build as CANCELLED.
773 - Delete any pending BuildItem objects (but do not remove items from stock)
774 - Set build status to CANCELLED
775 - Save the Build object
776 """
777 return self.handle_transition(
778 self.status,
779 BuildStatus.CANCELLED.value,
780 self,
781 self._action_cancel,
782 user=user,
783 **kwargs,
784 )
786 def _action_cancel(self, *args, **kwargs):
787 """Action to be taken when a build is cancelled."""
788 import build.tasks
790 user = kwargs.pop('user', None)
792 remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
793 remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False)
795 # Offload background task to take care of the expensive operations
796 InvenTree.tasks.offload_task(
797 build.tasks.cancel_build,
798 self.pk,
799 user.pk if user else None,
800 remove_allocated_stock=remove_allocated_stock,
801 remove_incomplete_outputs=remove_incomplete_outputs,
802 group='build',
803 )
805 # Date of 'completion' is the date the build was cancelled
806 self.completion_date = InvenTree.helpers.current_date()
807 self.completed_by = user
809 self.status = BuildStatus.CANCELLED.value
810 self.save()
812 @transaction.atomic
813 def deallocate_stock(self, build_line=None, output=None):
814 """Deallocate stock from this Build.
816 Args:
817 build_line: Specify a particular BuildLine instance to un-allocate stock against
818 output: Specify a particular StockItem (output) to un-allocate stock against
819 """
820 allocations = self.allocated_stock.filter(install_into=output)
822 if build_line:
823 allocations = allocations.filter(build_line=build_line)
825 allocations.delete()
827 @transaction.atomic
828 def create_build_output(self, quantity, **kwargs) -> QuerySet:
829 """Create a new build output against this BuildOrder.
831 Arguments:
832 quantity: The quantity of the item to produce
834 Kwargs:
835 batch: Override batch code
836 serials: Serial numbers
837 location: Override location
838 auto_allocate: Automatically allocate stock with matching serial numbers
840 Returns:
841 A QuerySet of the created output (StockItem) objects.
842 """
843 trackable_parts = self.part.get_trackable_parts()
845 # Create (and cache) a map of valid parts for allocation
846 valid_parts = {}
848 for bom_item in trackable_parts:
849 parts = bom_item.get_valid_parts_for_allocation()
850 valid_parts[bom_item.pk] = [part.pk for part in parts]
852 user = kwargs.get('user')
853 batch = kwargs.get('batch', self.batch)
854 location = kwargs.get('location')
855 serials = kwargs.get('serials')
856 auto_allocate = kwargs.get('auto_allocate', False)
858 if location is None:
859 location = self.destination or self.part.get_default_location()
861 if self.part.has_trackable_parts and not serials:
862 raise ValidationError({
863 'serials': _('Serial numbers must be provided for trackable parts')
864 })
866 outputs = []
868 # We are generating multiple serialized outputs
869 if serials:
870 """Create multiple build outputs with a single quantity of 1."""
872 # Create tracking entries for each item
873 tracking = []
874 allocations = []
876 outputs = stock.models.StockItem._create_serial_numbers(
877 serials,
878 part=self.part,
879 build=self,
880 batch=batch,
881 location=location,
882 is_building=True,
883 )
885 for output in outputs:
886 # Generate a new historical tracking entry
887 if entry := output.add_tracking_entry(
888 StockHistoryCode.BUILD_OUTPUT_CREATED,
889 user,
890 deltas={
891 'quantity': 1,
892 'buildorder': self.pk,
893 'batch': output.batch,
894 'serial': output.serial,
895 'location': location.pk if location else None,
896 },
897 commit=False,
898 ):
899 tracking.append(entry)
901 # Auto-allocate stock based on serial number
902 if auto_allocate:
903 if new_allocations := self.auto_allocate_tracked_output(
904 output, location=self.take_from
905 ):
906 allocations.extend(new_allocations)
908 # Bulk create tracking entries
909 stock.models.StockItemTracking.objects.bulk_create(tracking, batch_size=250)
911 # Generate stock allocations
912 BuildItem.objects.bulk_create(allocations, batch_size=250)
914 else:
915 """Create a single build output of the given quantity."""
917 output = stock.models.StockItem.objects.create(
918 quantity=quantity,
919 location=location,
920 part=self.part,
921 build=self,
922 batch=batch,
923 is_building=True,
924 )
926 output.add_tracking_entry(
927 StockHistoryCode.BUILD_OUTPUT_CREATED,
928 user,
929 deltas={
930 'quantity': float(quantity),
931 'buildorder': self.pk,
932 'batch': batch,
933 'location': location.pk if location else None,
934 },
935 )
937 # Ensure we return a QuerySet object here, too
938 outputs = stock.models.StockItem.objects.filter(pk=output.pk)
940 if self.status == BuildStatus.PENDING:
941 self.status = BuildStatus.PRODUCTION.value
942 self.save()
944 return outputs
946 @transaction.atomic
947 def delete_output(self, output):
948 """Remove a build output from the database.
950 Executes:
951 - Deallocate any build items against the output
952 - Delete the output StockItem
953 """
954 if not output:
955 raise ValidationError(_('No build output specified'))
957 if not output.is_building:
958 raise ValidationError(_('Build output is already completed'))
960 if output.build != self:
961 raise ValidationError(_('Build output does not match Build Order'))
963 # Deallocate all build items against the output
964 self.deallocate_stock(output=output)
966 # Remove the build output from the database
967 # This is a special case where serialized stock can be deleted,
968 # independent of the global setting which normally prevents deletion of serialized stock items
969 output.delete(ignore_serial_check=True)
971 @transaction.atomic
972 def trim_allocated_stock(self):
973 """Called after save to reduce allocated stock if the build order is now overallocated."""
974 # Only need to worry about untracked stock here
976 items_to_save = []
977 items_to_delete = []
979 lines = self.untracked_line_items.all()
980 lines = lines.exclude(bom_item__consumable=True)
981 lines = lines.annotate(allocated=annotate_allocated_quantity())
983 for build_line in lines:
984 reduce_by = build_line.allocated - build_line.quantity
986 if reduce_by <= 0:
987 continue
989 # Find BuildItem objects to trim
990 for item in BuildItem.objects.filter(build_line=build_line):
991 # Previous item completed the job
992 if reduce_by <= 0:
993 break
995 # Easy case - this item can just be reduced.
996 if item.quantity > reduce_by:
997 item.quantity -= reduce_by
998 items_to_save.append(item)
999 break
1001 # Harder case, this item needs to be deleted, and any remainder
1002 # taken from the next items in the list.
1003 reduce_by -= item.quantity
1004 items_to_delete.append(item)
1006 # Save the updated BuildItem objects
1007 BuildItem.objects.bulk_update(items_to_save, ['quantity'])
1009 # Delete the remaining BuildItem objects
1010 BuildItem.objects.filter(pk__in=[item.pk for item in items_to_delete]).delete()
1012 @property
1013 def allocated_stock(self) -> QuerySet:
1014 """Returns a QuerySet object of all BuildItem objects which point back to this Build."""
1015 return BuildItem.objects.filter(build_line__build=self)
1017 def subtract_allocated_stock(self, user) -> None:
1018 """Removes the allocated untracked items from stock."""
1019 # Find all BuildItem objects which point to this build
1020 items = self.allocated_stock.filter(
1021 build_line__bom_item__sub_part__trackable=False
1022 )
1024 # Remove stock
1025 for item in items:
1026 item.complete_allocation(user=user)
1028 # Delete allocation
1029 items.all().delete()
1031 @transaction.atomic
1032 def scrap_build_output(
1033 self, output: stock.models.StockItem, quantity, location, **kwargs
1034 ):
1035 """Mark a particular build output as scrapped / rejected.
1037 - Mark the output as "complete"
1038 - *Do Not* update the "completed" count for this order
1039 - Set the item status to "scrapped"
1040 - Add a transaction entry to the stock item history
1041 """
1042 if not output:
1043 raise ValidationError(_('No build output specified'))
1045 # If quantity is not specified, assume the entire output quantity
1046 if quantity is None:
1047 quantity = output.quantity
1049 if quantity <= 0:
1050 raise ValidationError({'quantity': _('Quantity must be greater than zero')})
1052 if quantity > output.quantity:
1053 raise ValidationError({
1054 'quantity': _('Quantity cannot be greater than the output quantity')
1055 })
1057 user = kwargs.get('user')
1058 notes = kwargs.get('notes', '')
1059 discard_allocations = kwargs.get('discard_allocations', False)
1061 if quantity < output.quantity:
1062 # Split output into two items
1063 output = output.splitStock(
1064 quantity, location=location, user=user, allow_production=True
1065 )
1066 output.build = self
1068 # Update build output item
1069 output.is_building = False
1070 output.status = StockStatus.REJECTED.value
1071 output.location = location
1072 output.save(add_note=False)
1074 allocated_items = output.items_to_install.all()
1076 # Complete or discard allocations
1077 for build_item in allocated_items:
1078 if not discard_allocations:
1079 build_item.complete_allocation(user=user)
1081 # Delete allocations
1082 allocated_items.delete()
1084 output.add_tracking_entry(
1085 StockHistoryCode.BUILD_OUTPUT_REJECTED,
1086 user,
1087 notes=notes,
1088 deltas={
1089 'quantity': float(quantity),
1090 'location': location.pk,
1091 'status': StockStatus.REJECTED.value,
1092 'buildorder': self.pk,
1093 },
1094 )
1096 @transaction.atomic
1097 def complete_build_output(
1098 self,
1099 output: stock.models.StockItem,
1100 user: User,
1101 quantity: Optional[decimal.Decimal] = None,
1102 **kwargs,
1103 ):
1104 """Complete a particular build output.
1106 Arguments:
1107 output: The StockItem instance (build output) to complete
1108 user: The user who is completing the build output
1109 quantity: The quantity to complete (defaults to entire output quantity)
1111 Notes:
1112 - Remove allocated StockItems
1113 - Mark the output as complete
1114 """
1115 # Select the location for the build output
1116 location = kwargs.get('location', self.destination)
1117 status = kwargs.get('status', StockStatus.OK.value)
1118 notes = kwargs.get('notes', '')
1120 required_tests = kwargs.get('required_tests', output.part.getRequiredTests())
1121 prevent_on_incomplete = kwargs.get(
1122 'prevent_on_incomplete',
1123 prevent_build_output_complete_on_incompleted_tests(),
1124 )
1126 if prevent_on_incomplete and not output.passedAllRequiredTests(
1127 required_tests=required_tests
1128 ):
1129 msg = _('Build output has not passed all required tests')
1131 if serial := output.serial:
1132 msg = _(f'Build output {serial} has not passed all required tests')
1134 raise ValidationError(msg)
1136 # List the allocated BuildItem objects for the given output
1137 allocated_items = output.items_to_install.all()
1139 # Ensure that none of the allocated items are themselves still "in production"
1140 for build_item in allocated_items:
1141 if build_item.stock_item.is_building:
1142 raise ValidationError(
1143 _('Allocated stock items are still in production')
1144 )
1146 # If a partial quantity is provided, split the stock output
1147 if quantity is not None and quantity != output.quantity:
1148 # Cannot split a build output with allocated items
1149 if allocated_items.count() > 0:
1150 raise ValidationError(
1151 _('Cannot partially complete a build output with allocated items')
1152 )
1154 if quantity <= 0:
1155 raise ValidationError({
1156 'quantity': _('Quantity must be greater than zero')
1157 })
1159 if quantity > output.quantity:
1160 raise ValidationError({
1161 'quantity': _('Quantity cannot be greater than the output quantity')
1162 })
1164 # Split the stock item
1165 output = output.splitStock(quantity, user=user, allow_production=True)
1167 for build_item in allocated_items:
1168 # Complete the allocation of stock for that item
1169 build_item.complete_allocation(user=user)
1171 # Delete the BuildItem objects from the database
1172 allocated_items.all().delete()
1174 # Ensure that the output is updated correctly
1175 output.build = self
1176 output.is_building = False
1177 output.location = location
1179 # Assign the stock status
1180 output.set_status(status)
1182 output.save(add_note=False)
1184 deltas = {
1185 'status': status,
1186 'buildorder': self.pk,
1187 'quantity': float(output.quantity),
1188 }
1190 if location:
1191 deltas['location'] = location.pk
1193 output.add_tracking_entry(
1194 StockHistoryCode.BUILD_OUTPUT_COMPLETED, user, notes=notes, deltas=deltas
1195 )
1197 trigger_event(BuildEvents.OUTPUT_COMPLETED, id=output.pk, build_id=self.pk)
1199 # Increase the completed quantity for this build
1200 # Increment at the database level to prevent lost updates
1201 # (multiple outputs may be completed concurrently)
1202 self.completed = F('completed') + output.quantity
1203 self.save(update_fields=['completed'])
1204 self.refresh_from_db(fields=['completed'])
1206 @transaction.atomic
1207 def auto_allocate_stock(
1208 self, item_type: str = BuildItemTypes.UNTRACKED, **kwargs
1209 ) -> None:
1210 """Automatically allocate stock items against this build order.
1212 Arguments:
1213 item_type: The type of BuildItem to allocate (default = untracked)
1214 """
1215 if item_type in [self.BuildItemTypes.UNTRACKED, self.BuildItemTypes.ALL]:
1216 self.auto_allocate_untracked_stock(**kwargs)
1218 if item_type in [self.BuildItemTypes.TRACKED, self.BuildItemTypes.ALL]:
1219 self.auto_allocate_tracked_stock(**kwargs)
1221 def auto_allocate_tracked_output(self, output, **kwargs):
1222 """Auto-allocate tracked stock items against a particular build output.
1224 This may occur at the time of build output creation, or later when triggered manually.
1225 """
1226 location = kwargs.get('location')
1227 exclude_location = kwargs.get('exclude_location')
1228 substitutes = kwargs.get('substitutes', True)
1229 optional_items = kwargs.get('optional_items', False)
1231 # Newly created allocations (not yet committed to the database)
1232 allocations = []
1234 # Return early if the output should not be auto-allocated
1235 if not output.serialized:
1236 return allocations
1238 tracked_line_items = self.tracked_line_items.filter(
1239 bom_item__consumable=False, bom_item__sub_part__virtual=False
1240 )
1242 for line_item in tracked_line_items:
1243 bom_item = line_item.bom_item
1245 if bom_item.consumable:
1246 # Do not auto-allocate stock to consumable BOM items
1247 continue
1249 if bom_item.optional and not optional_items:
1250 # User has specified that optional_items are to be ignored
1251 continue
1253 # If the line item is already fully allocated, we can continue
1254 if line_item.is_fully_allocated():
1255 continue
1257 # If there is already allocated stock against this build output, skip it
1258 if line_item.allocated_quantity(output=output) > 0:
1259 continue
1261 # Find available parts (may include variants and substitutes)
1262 available_parts = bom_item.get_valid_parts_for_allocation(
1263 allow_variants=True, allow_substitutes=substitutes
1264 )
1266 # Find stock items which match the output serial number
1267 available_stock = stock.models.StockItem.objects.filter(
1268 part__in=list(available_parts),
1269 part__active=True,
1270 part__virtual=False,
1271 serial=output.serial,
1272 ).exclude(Q(serial=None) | Q(serial=''))
1274 if location:
1275 # Filter only stock items located "below" the specified location
1276 sublocations = location.get_descendants(include_self=True)
1277 available_stock = available_stock.filter(
1278 location__in=list(sublocations)
1279 )
1281 if exclude_location:
1282 # Exclude any stock items from the provided location
1283 sublocations = exclude_location.get_descendants(include_self=True)
1284 available_stock = available_stock.exclude(
1285 location__in=list(sublocations)
1286 )
1288 # Filter stock items to only those which are in stock
1289 # Note that we can accept "in production" items here
1290 available_items = list(
1291 filter(
1292 lambda item: item.is_in_stock(check_in_production=False),
1293 available_stock,
1294 )
1295 )
1297 if len(available_items) == 1:
1298 allocations.append(
1299 BuildItem(
1300 build_line=line_item,
1301 stock_item=available_items[0],
1302 quantity=1,
1303 install_into=output,
1304 )
1305 )
1307 return allocations
1309 def auto_allocate_tracked_stock(self, **kwargs):
1310 """Automatically allocate tracked stock items against serialized build outputs.
1312 This function allocates tracked stock items automatically against serialized build outputs,
1313 following a set of "guidelines":
1315 - Only "tracked" BOM items are considered (untracked BOM items must be allocated separately)
1316 - Only build outputs with serial numbers are considered
1317 - Unallocated tracked components are allocated against build outputs with matching serial numbers
1318 """
1319 new_items = []
1321 # Select only "tracked" line items
1322 for output in self.incomplete_outputs.all():
1323 new_items.extend(self.auto_allocate_tracked_output(output, **kwargs))
1325 # Bulk-create the new BuildItem objects
1326 BuildItem.objects.bulk_create(new_items, batch_size=250)
1328 def auto_allocate_untracked_stock(self, **kwargs):
1329 """Automatically allocate untracked stock items against this build order.
1331 This function allocates untracked stock items automatically against a BuildOrder,
1332 following a set of "guidelines":
1334 - Only "untracked" BOM items are considered (tracked BOM items must be allocated separately)
1335 - If a particular BOM item is already fully allocated, it is skipped
1336 - Extract all available stock items for the BOM part
1337 - If variant stock is allowed, extract stock for those too
1338 - If substitute parts are available, extract stock for those also
1339 - If a single stock item is found, we can allocate that and move on!
1340 - If multiple stock items are found, we *may* be able to allocate:
1341 - If the calling function has specified that items are interchangeable
1342 """
1343 location = kwargs.get('location')
1344 exclude_location = kwargs.get('exclude_location')
1345 interchangeable = kwargs.get('interchangeable', False)
1346 substitutes = kwargs.get('substitutes', True)
1347 optional_items = kwargs.get('optional_items', False)
1348 stock_sort_by = kwargs.get('stock_sort_by', stock.models.STOCK_SORT_DEFAULT)
1349 line_ids = kwargs.get('line_ids')
1351 def stock_sort(item, bom_item, variant_parts):
1352 if item.part == bom_item.sub_part:
1353 return 1
1354 elif item.part in variant_parts:
1355 return 2
1356 return 3
1358 new_items = []
1360 # Select only "untracked" line items
1361 untracked_lines = self.untracked_line_items.all()
1362 if line_ids:
1363 untracked_lines = untracked_lines.filter(pk__in=line_ids)
1365 for line_item in untracked_lines:
1366 # Find the referenced BomItem
1367 bom_item = line_item.bom_item
1369 if bom_item.consumable:
1370 # Do not auto-allocate stock to consumable BOM items
1371 continue
1373 if bom_item.optional and not optional_items:
1374 # User has specified that optional_items are to be ignored
1375 continue
1377 variant_parts = bom_item.sub_part.get_descendants(include_self=False)
1379 unallocated_quantity = line_item.unallocated_quantity()
1381 if unallocated_quantity <= 0:
1382 # This BomItem is fully allocated, we can continue
1383 continue
1385 # Check which parts we can "use" (may include variants and substitutes)
1386 available_parts = bom_item.get_valid_parts_for_allocation(
1387 allow_variants=True, allow_inactive=False, allow_substitutes=substitutes
1388 )
1390 # Look for available stock items
1391 available_stock = stock.models.StockItem.objects.filter(
1392 stock.models.StockItem.IN_STOCK_FILTER
1393 )
1395 # Filter by list of available parts
1396 available_stock = available_stock.filter(part__in=list(available_parts))
1398 # Ensure part is active and not virtual
1399 available_stock = available_stock.filter(
1400 part__active=True, part__virtual=False
1401 )
1403 # Filter out "serialized" stock items, these cannot be auto-allocated
1404 available_stock = available_stock.filter(
1405 Q(serial=None) | Q(serial='')
1406 ).distinct()
1408 if location:
1409 # Filter only stock items located "below" the specified location
1410 sublocations = location.get_descendants(include_self=True)
1411 available_stock = available_stock.filter(
1412 location__in=list(sublocations)
1413 )
1415 if exclude_location:
1416 # Exclude any stock items from the provided location
1417 sublocations = exclude_location.get_descendants(include_self=True)
1418 available_stock = available_stock.exclude(
1419 location__in=list(sublocations)
1420 )
1422 # Apply secondary ORM ordering before the Python match-quality stable-sort.
1423 if stock_sort_by == stock.models.StockSortOrder.EXPIRY_SOONEST:
1424 available_stock = available_stock.order_by(
1425 F('expiry_date').asc(nulls_last=True)
1426 )
1427 else:
1428 available_stock = available_stock.order_by(stock_sort_by)
1430 """
1431 Next, we sort the available stock items with the following priority:
1432 1. Direct part matches (+1)
1433 2. Variant part matches (+2)
1434 3. Substitute part matches (+3)
1436 This ensures that allocation priority is first given to "direct" parts.
1437 Python's stable sort preserves the secondary ORM ordering within each group.
1438 """
1439 available_stock = sorted(
1440 available_stock,
1441 key=lambda item, b=bom_item, v=variant_parts: stock_sort(item, b, v),
1442 )
1444 if len(available_stock) == 1 or interchangeable:
1445 # Either there is only a single stock item available,
1446 # or all items are "interchangeable" and we don't care where we take stock from
1448 for stock_item in available_stock:
1449 # Skip inactive parts
1450 if not stock_item.part.active:
1451 continue
1453 # How much of the stock item is "available" for allocation?
1454 quantity = min(
1455 unallocated_quantity, stock_item.unallocated_quantity()
1456 )
1458 if quantity > 0:
1459 try:
1460 new_items.append(
1461 BuildItem(
1462 build_line=line_item,
1463 stock_item=stock_item,
1464 quantity=quantity,
1465 )
1466 )
1468 # Subtract the required quantity
1469 unallocated_quantity -= quantity
1471 except (ValidationError, serializers.ValidationError) as exc:
1472 # Re-raise with a Django-compatible validation payload
1473 raise ValidationError(
1474 serializers.as_serializer_error(exc)
1475 ) from exc
1477 if unallocated_quantity <= 0:
1478 # We have now fully-allocated this BomItem - no need to continue!
1479 break
1481 # Bulk-create the new BuildItem objects
1482 BuildItem.objects.bulk_create(new_items, batch_size=250)
1484 def unallocated_lines(self, tracked: Optional[bool] = None) -> QuerySet:
1485 """Returns a list of BuildLine objects which have not been fully allocated."""
1486 lines = self.build_lines.all()
1488 # Remove any 'consumable' line items
1489 lines = lines.exclude(bom_item__consumable=True)
1491 if tracked is True:
1492 lines = lines.filter(bom_item__sub_part__trackable=True)
1493 elif tracked is False:
1494 lines = lines.filter(bom_item__sub_part__trackable=False)
1496 lines = lines.prefetch_related('allocations')
1498 lines = lines.annotate(
1499 allocated=annotate_allocated_quantity(),
1500 required=annotate_required_quantity(),
1501 ).filter(allocated__lt=F('required'))
1503 return lines
1505 def is_fully_allocated(self, tracked: Optional[bool] = None) -> bool:
1506 """Test if the BuildOrder has been fully allocated.
1508 Arguments:
1509 tracked: If True, only consider tracked BuildLine items. If False, only consider untracked BuildLine items.
1511 Returns:
1512 True if the BuildOrder has been fully allocated, otherwise False
1513 """
1514 return self.unallocated_lines(tracked=tracked).count() == 0
1516 def is_output_fully_allocated(self, output) -> bool:
1517 """Determine if the specified output (StockItem) has been fully allocated for this build.
1519 Arguments:
1520 output: StockItem object (the "in production" output to test against)
1522 To determine if the output has been fully allocated,
1523 we need to test all "trackable" BuildLine objects
1524 """
1525 lines = self.build_lines.filter(bom_item__sub_part__trackable=True)
1526 lines = lines.exclude(bom_item__consumable=True)
1528 # Find any lines which have not been fully allocated
1529 for line in lines:
1530 # Grab all BuildItem objects which point to this output
1531 allocations = BuildItem.objects.filter(build_line=line, install_into=output)
1533 allocated = allocations.aggregate(
1534 q=Coalesce(Sum('quantity'), 0, output_field=models.DecimalField())
1535 )
1537 # The amount allocated against an output must at least equal the BOM quantity
1538 if allocated['q'] < line.bom_item.quantity:
1539 return False
1541 # At this stage, we can assume that the output is fully allocated
1542 return True
1544 def is_overallocated(self) -> bool:
1545 """Test if the BuildOrder has been over-allocated.
1547 Returns:
1548 True if any BuildLine has been over-allocated.
1549 """
1550 lines = self.build_lines.all().exclude(bom_item__consumable=True)
1552 lines = lines.prefetch_related('allocations')
1554 # Find any lines which have been over-allocated
1555 lines = lines.annotate(
1556 allocated=annotate_allocated_quantity(),
1557 required=annotate_required_quantity(),
1558 ).filter(allocated__gt=F('required'))
1560 return lines.count() > 0
1562 @property
1563 def is_active(self) -> bool:
1564 """Is this build active?
1566 An active build is either:
1567 - PENDING
1568 - HOLDING
1569 """
1570 return self.status in BuildStatusGroups.ACTIVE_CODES
1572 @property
1573 def is_complete(self) -> bool:
1574 """Returns True if the build status is COMPLETE."""
1575 return self.status == BuildStatus.COMPLETE.value
1577 @transaction.atomic
1578 def create_build_line_items(self, prevent_duplicates: bool = True) -> None:
1579 """Create BuildLine objects for each BOM line in this BuildOrder."""
1580 lines = []
1582 # Find all non-virtual BOM items for the parent part
1583 bom_items = self.part.get_bom_items(include_virtual=False)
1585 logger.info(
1586 'Creating BuildLine objects for BuildOrder %s (%s items)',
1587 self.pk,
1588 len(bom_items),
1589 )
1591 # Iterate through each part required to build the parent part
1592 for bom_item in bom_items:
1593 if prevent_duplicates:
1594 if BuildLine.objects.filter(build=self, bom_item=bom_item).exists():
1595 logger.info(
1596 'BuildLine already exists for BuildOrder %s and BomItem %s',
1597 self.pk,
1598 bom_item.pk,
1599 )
1600 continue
1602 # Calculate required quantity
1603 quantity = bom_item.get_required_quantity(self.quantity)
1605 lines.append(BuildLine(build=self, bom_item=bom_item, quantity=quantity))
1607 BuildLine.objects.bulk_create(lines, batch_size=250)
1609 if len(lines) > 0:
1610 logger.info('Created %s BuildLine objects for BuildOrder', len(lines))
1612 @transaction.atomic
1613 def update_build_line_items(self) -> None:
1614 """Rebuild required quantity field for each BuildLine object."""
1615 lines_to_update = []
1617 for line in self.build_lines.all():
1618 line.quantity = line.bom_item.get_required_quantity(self.quantity)
1619 lines_to_update.append(line)
1621 BuildLine.objects.bulk_update(lines_to_update, ['quantity'])
1623 logger.info('Updated %s BuildLine objects for BuildOrder', len(lines_to_update))
1626@receiver(post_save, sender=Build, dispatch_uid='build_post_save_log')
1627def after_save_build(sender, instance: Build, created: bool, **kwargs):
1628 """Callback function to be executed after a Build instance is saved."""
1629 # Escape if we are importing data
1630 if InvenTree.ready.isImportingData() or not InvenTree.ready.canAppAccessDatabase(
1631 allow_test=True
1632 ):
1633 return
1635 if instance:
1636 if created:
1637 # A new Build has just been created
1639 # Generate initial BuildLine objects for the Build
1640 instance.create_build_line_items()
1642 # Notify the responsible users that the build order has been created
1643 InvenTree.helpers_model.notify_responsible(
1644 instance,
1645 sender,
1646 exclude=instance.issued_by,
1647 extra_users=instance.part.get_subscribers(),
1648 )
1650 else:
1651 # Update BuildLine objects if the Build quantity has changed
1652 instance.update_build_line_items()
1655class BuildLineReportContext(report.mixins.BaseReportContext, TypedDict):
1656 """Context for the BuildLine model.
1658 Attributes:
1659 allocated_quantity: The quantity of the part which has been allocated to this build
1660 allocations: A query set of all StockItem objects which have been allocated to this build line
1661 bom_item: The BomItem associated with this line item
1662 build: The BuildOrder instance associated with this line item
1663 build_line: The build line instance itself
1664 part: The sub-part (component) associated with the linked BomItem instance
1665 quantity: The quantity required for this line item
1666 """
1668 allocated_quantity: decimal.Decimal
1669 allocations: report.mixins.QuerySet['BuildItem']
1670 bom_item: part.models.BomItem
1671 build: Build
1672 build_line: 'BuildLine'
1673 part: part.models.Part
1674 quantity: decimal.Decimal
1677class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeModel):
1678 """A BuildLine object links a BOMItem to a Build.
1680 When a new Build is created, the BuildLine objects are created automatically.
1681 - A BuildLine entry is created for each BOM item associated with the part
1682 - The quantity is set to the quantity required to build the part
1683 - BuildItem objects are associated with a particular BuildLine
1685 Once a build has been created, BuildLines can (optionally) be removed from the Build
1687 Attributes:
1688 build: Link to a Build object
1689 bom_item: Link to a BomItem object
1690 quantity: Number of units required for the Build
1691 consumed: Number of units which have been consumed against this line item
1692 """
1694 class Meta:
1695 """Model meta options."""
1697 verbose_name = _('Build Order Line Item')
1698 unique_together = [('build', 'bom_item')]
1700 @staticmethod
1701 def get_api_url():
1702 """Return the API URL used to access this model."""
1703 return reverse('api-build-line-list')
1705 # type
1706 def report_context(self) -> BuildLineReportContext:
1707 """Generate custom report context for this BuildLine object."""
1708 return {
1709 'allocated_quantity': self.allocated_quantity,
1710 'allocations': self.allocations,
1711 'bom_item': self.bom_item,
1712 'build': self.build,
1713 'build_line': self,
1714 'part': self.bom_item.sub_part,
1715 'quantity': self.quantity,
1716 }
1718 build = models.ForeignKey(
1719 Build,
1720 on_delete=models.CASCADE,
1721 related_name='build_lines',
1722 help_text=_('Build object'),
1723 )
1725 bom_item = models.ForeignKey(
1726 part.models.BomItem, on_delete=models.CASCADE, related_name='build_lines'
1727 )
1729 quantity = models.DecimalField(
1730 decimal_places=5,
1731 max_digits=15,
1732 default=1,
1733 validators=[MinValueValidator(0)],
1734 verbose_name=_('Quantity'),
1735 help_text=_('Required quantity for build order'),
1736 )
1738 consumed = models.DecimalField(
1739 decimal_places=5,
1740 max_digits=15,
1741 default=0,
1742 validators=[MinValueValidator(0)],
1743 verbose_name=_('Consumed'),
1744 help_text=_('Quantity of consumed stock'),
1745 )
1747 @property
1748 def part(self):
1749 """Return the sub_part reference from the link bom_item."""
1750 return self.bom_item.sub_part
1752 def allocated_quantity(self, output: Optional[stock.models.StockItem] = None):
1753 """Calculate the total allocated quantity for this BuildLine."""
1754 # Queryset containing all BuildItem objects allocated against this BuildLine
1755 allocations = self.allocations.all()
1757 if output is not None:
1758 allocations = allocations.filter(install_into=output)
1760 allocated = allocations.aggregate(
1761 q=Coalesce(Sum('quantity'), 0, output_field=models.DecimalField())
1762 )
1764 return allocated['q']
1766 def unallocated_quantity(self):
1767 """Return the unallocated quantity for this BuildLine.
1769 - Start with the required quantity
1770 - Subtract the consumed quantity
1771 - Subtract the allocated quantity
1773 Return the remaining quantity (or zero if negative)
1774 """
1775 return max(self.quantity - self.consumed - self.allocated_quantity(), 0)
1777 def is_fully_allocated(self) -> bool:
1778 """Return True if this BuildLine is fully allocated."""
1779 if self.bom_item.consumable:
1780 return True
1782 required = max(0, self.quantity - self.consumed)
1784 return self.allocated_quantity() >= required
1786 def is_overallocated(self):
1787 """Return True if this BuildLine is over-allocated."""
1788 required = max(0, self.quantity - self.consumed)
1790 return self.allocated_quantity() > required
1792 def is_fully_consumed(self) -> bool:
1793 """Return True if this BuildLine is fully consumed."""
1794 return self.consumed >= self.quantity
1797class BuildItem(InvenTree.models.InvenTreeMetadataModel):
1798 """A BuildItem links multiple StockItem objects to a Build.
1800 These are used to allocate part stock to a build. Once the Build is completed, the parts are removed from stock and the BuildItemAllocation objects are removed.
1802 Attributes:
1803 build: Link to a Build object
1804 build_line: Link to a BuildLine object (this is a "line item" within a build)
1805 stock_item: Link to a StockItem object
1806 quantity: Number of units allocated
1807 install_into: Destination stock item (or None)
1808 """
1810 class Meta:
1811 """Model meta options."""
1813 unique_together = [('build_line', 'stock_item', 'install_into')]
1815 @staticmethod
1816 def get_api_url():
1817 """Return the API URL used to access this model."""
1818 return reverse('api-build-item-list')
1820 def save(self, *args, **kwargs):
1821 """Custom save method for the BuildItem model."""
1822 self.clean(raise_error=False)
1823 super().save()
1825 def clean(self, raise_error: bool = True):
1826 """Check validity of this BuildItem instance.
1828 The following checks are performed:
1829 - StockItem.part must be in the BOM of the Part object referenced by Build
1830 - Allocation quantity cannot exceed available quantity
1831 """
1832 self.validate_unique()
1834 super().clean()
1836 try:
1837 # If the 'part' is trackable, then the 'install_into' field must be set!
1838 if (
1839 self.stock_item.part
1840 and self.stock_item.part.trackable
1841 and not self.install_into
1842 ):
1843 raise ValidationError(
1844 _(
1845 'Build item must specify a build output, as master part is marked as trackable'
1846 )
1847 )
1849 self.check_allocated_quantity(raise_error=raise_error)
1851 except stock.models.StockItem.DoesNotExist:
1852 raise ValidationError('Stock item must be specified')
1853 except part.models.Part.DoesNotExist:
1854 raise ValidationError('Part must be specified')
1856 """
1857 Attempt to find the "BomItem" which links this BuildItem to the build.
1859 - If a BomItem is already set, and it is valid, then we are ok!
1860 """
1862 valid = False
1864 if self.bom_item and self.build:
1865 """
1866 A BomItem object has already been assigned. This is valid if:
1868 a) It points to the same "part" as the referenced build
1869 b) Either:
1870 i) The sub_part points to the same part as the referenced StockItem
1871 ii) The BomItem allows variants and the part referenced by the StockItem
1872 is a variant of the sub_part referenced by the BomItem
1873 iii) The Part referenced by the StockItem is a valid substitute for the BomItem
1874 """
1876 if self.build.part == self.bom_item.part:
1877 valid = self.bom_item.is_stock_item_valid(self.stock_item)
1879 elif self.bom_item.inherited:
1880 if self.build.part in self.bom_item.part.get_descendants(
1881 include_self=False
1882 ):
1883 valid = self.bom_item.is_stock_item_valid(self.stock_item)
1885 # If the existing BomItem is *not* valid, try to find a match
1886 if not valid and self.build and self.stock_item:
1887 ancestors = self.stock_item.part.get_ancestors(
1888 include_self=True, ascending=True
1889 )
1891 for idx, ancestor in enumerate(ancestors):
1892 build_line = BuildLine.objects.filter(
1893 build=self.build, bom_item__part=ancestor
1894 )
1896 if build_line.exists():
1897 line = build_line.first()
1899 if idx == 0 or line.bom_item.allow_variants:
1900 valid = True
1901 self.build_line = line
1902 break
1904 # BomItem did not exist or could not be validated.
1905 # Search for a new one
1906 if not valid:
1907 raise ValidationError({
1908 'stock_item': _('Selected stock item does not match BOM line')
1909 })
1911 def check_allocated_quantity(self, raise_error: bool = False):
1912 """Ensure that the allocated quantity is valid.
1914 Will reduce the allocated quantity if it exceeds available stock.
1916 Arguments:
1917 raise_error: If True, raise ValidationError on failure
1919 Raises:
1920 ValidationError: If the allocated quantity is invalid and raise_error is True
1921 """
1922 error = None
1924 # Allocated quantity must be positive
1925 if self.quantity <= 0:
1926 self.quantity = 0
1927 error = {'quantity': _('Allocated quantity must be greater than zero')}
1929 # Quantity must be 1 for serialized stock
1930 if self.stock_item.serialized and self.quantity != 1:
1931 self.quantity = 1
1932 raise ValidationError({
1933 'quantity': _('Quantity must be 1 for serialized stock')
1934 })
1936 # Allocated quantity cannot exceed available stock quantity
1937 if self.quantity > self.stock_item.quantity:
1938 q = InvenTree.helpers.normalize(self.quantity)
1939 a = InvenTree.helpers.normalize(self.stock_item.quantity)
1940 self.quantity = self.stock_item.quantity
1941 error = {
1942 'quantity': _(
1943 f'Allocated quantity ({q}) must not exceed available stock quantity ({a})'
1944 )
1945 }
1947 # Ensure that we do not 'over allocate' a stock item
1948 available = decimal.Decimal(self.stock_item.quantity)
1949 quantity = decimal.Decimal(self.quantity)
1950 build_allocation_count = decimal.Decimal(
1951 self.stock_item.build_allocation_count(exclude_allocations={'pk': self.pk})
1952 )
1953 sales_allocation_count = decimal.Decimal(
1954 self.stock_item.sales_order_allocation_count()
1955 )
1957 total_allocation = build_allocation_count + sales_allocation_count + quantity
1959 if total_allocation > available:
1960 error = {'quantity': _('Stock item is over-allocated')}
1962 if error and raise_error:
1963 raise ValidationError(error)
1965 @property
1966 def build(self):
1967 """Return the BuildOrder associated with this BuildItem."""
1968 return self.build_line.build if self.build_line else None
1970 @property
1971 def bom_item(self):
1972 """Return the BomItem associated with this BuildItem."""
1973 return self.build_line.bom_item if self.build_line else None
1975 @transaction.atomic
1976 def complete_allocation(self, quantity=None, notes: str = '', user=None) -> None:
1977 """Complete the allocation of this BuildItem into the output stock item.
1979 Arguments:
1980 quantity: The quantity to allocate (default is the full quantity)
1981 notes: Additional notes to add to the transaction
1982 user: The user completing the allocation
1984 - If the referenced part is trackable, the stock item will be *installed* into the build output
1985 - If the referenced part is *not* trackable, the stock item will be *consumed* by the build order
1987 TODO: This is quite expensive (in terms of number of database hits) - and requires some thought
1988 TODO: Revisit, and refactor!
1990 """
1991 # If the quantity is not provided, use the quantity of this BuildItem
1992 if quantity is None:
1993 quantity = self.quantity
1995 item = self.stock_item
1997 # Ensure we are not allocating more than available
1998 if quantity > item.quantity:
1999 quantity = item.quantity
2001 if quantity <= 0:
2002 # There is nothing to consume or install:
2003 # simply remove this (empty) allocation
2004 self.delete()
2005 return
2007 # Split the allocated stock if there are more available than allocated
2008 if item.quantity > quantity:
2009 item = item.splitStock(quantity, None, user, notes=notes)
2011 # For a trackable part, special consideration needed!
2012 if item.part.trackable:
2013 # Make sure we are pointing to the new item
2014 self.stock_item = item
2015 self.save()
2017 # Install the stock item into the output
2018 self.install_into.installStockItem(
2019 item, quantity, user, notes, build=self.build
2020 )
2022 else:
2023 # Mark the item as "consumed" by the build order
2024 item.consumed_by = self.build
2025 item.location = None
2026 item.save(add_note=False)
2028 item.add_tracking_entry(
2029 StockHistoryCode.BUILD_CONSUMED,
2030 user,
2031 notes=notes,
2032 deltas={'buildorder': self.build.pk, 'quantity': float(item.quantity)},
2033 )
2035 # Increase the "consumed" count for the associated BuildLine
2036 # Increment at the database level to prevent lost updates
2037 # (multiple allocations against the same BuildLine may complete concurrently)
2038 self.build_line.consumed = F('consumed') + quantity
2039 self.build_line.save(update_fields=['consumed'])
2040 self.build_line.refresh_from_db(fields=['consumed'])
2042 # Decrease the allocated quantity
2043 self.quantity = max(0, self.quantity - quantity)
2045 if self.quantity <= 0:
2046 self.delete()
2047 else:
2048 self.save()
2050 build_line = models.ForeignKey(
2051 BuildLine, on_delete=models.CASCADE, null=True, related_name='allocations'
2052 )
2054 stock_item = models.ForeignKey(
2055 'stock.StockItem',
2056 on_delete=models.CASCADE,
2057 related_name='allocations',
2058 verbose_name=_('Stock Item'),
2059 help_text=_('Source stock item'),
2060 limit_choices_to={'sales_order': None, 'belongs_to': None},
2061 )
2063 quantity = models.DecimalField(
2064 decimal_places=5,
2065 max_digits=15,
2066 default=1,
2067 validators=[MinValueValidator(0)],
2068 verbose_name=_('Quantity'),
2069 help_text=_('Stock quantity to allocate to build'),
2070 )
2072 install_into = models.ForeignKey(
2073 'stock.StockItem',
2074 on_delete=models.SET_NULL,
2075 blank=True,
2076 null=True,
2077 related_name='items_to_install',
2078 verbose_name=_('Install into'),
2079 help_text=_('Destination stock item'),
2080 limit_choices_to={'is_building': True},
2081 )