class Build(MPTTModel): """ A Build object organises the creation of new StockItem objects from other existing StockItem objects. Attributes: part: The part to be built (from component BOM items) reference: Build order reference (required, must be unique) title: Brief title describing the build (required) quantity: Number of units to be built parent: Reference to a Build object for which this Build is required 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) take_from: Location to take stock from to make this build (if blank, can take from anywhere) status: Build status code batch: Batch code transferred to build parts (optional) creation_date: Date the build was created (auto) target_date: Date the build will be overdue completion_date: Date the build was completed (or, if incomplete, the expected date of completion) link: External URL for extra information notes: Text notes completed_by: User that completed the build issued_by: User that issued the build responsible: User (or group) responsible for completing the build """ OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q( target_date=None) & Q(target_date__lte=datetime.now().date()) class Meta: verbose_name = _("Build Order") verbose_name_plural = _("Build Orders") def format_barcode(self, **kwargs): """ Return a JSON string to represent this build as a barcode """ return MakeBarcode("buildorder", self.pk, { "reference": self.title, "url": self.get_absolute_url(), }) @staticmethod def filterByDate(queryset, min_date, max_date): """ Filter by 'minimum and maximum date range' - Specified as min_date, max_date - Both must be specified for filter to be applied """ date_fmt = '%Y-%m-%d' # ISO format date string # Ensure that both dates are valid try: min_date = datetime.strptime(str(min_date), date_fmt).date() max_date = datetime.strptime(str(max_date), date_fmt).date() except (ValueError, TypeError): # Date processing error, return queryset unchanged return queryset # Order was completed within the specified range completed = Q(status=BuildStatus.COMPLETE) & Q( completion_date__gte=min_date) & Q(completion_date__lte=max_date) # Order target date falls witin specified range pending = Q( status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q( target_date__gte=min_date) & Q(target_date__lte=max_date) # TODO - Construct a queryset for "overdue" orders queryset = queryset.filter(completed | pending) return queryset def __str__(self): prefix = getSetting("BUILDORDER_REFERENCE_PREFIX") return f"{prefix}{self.reference}" def get_absolute_url(self): return reverse('build-detail', kwargs={'pk': self.id}) reference = models.CharField(unique=True, max_length=64, blank=False, help_text=_('Build Order Reference'), verbose_name=_('Reference'), validators=[validate_build_order_reference]) title = models.CharField(verbose_name=_('Description'), blank=False, max_length=100, help_text=_('Brief description of the build')) # TODO - Perhaps delete the build "tree" parent = TreeForeignKey( 'self', on_delete=models.SET_NULL, blank=True, null=True, related_name='children', verbose_name=_('Parent Build'), help_text=_('BuildOrder to which this build is allocated'), ) part = models.ForeignKey( 'part.Part', verbose_name=_('Part'), on_delete=models.CASCADE, related_name='builds', limit_choices_to={ 'assembly': True, 'active': True, 'virtual': False, }, help_text=_('Select part to build'), ) sales_order = models.ForeignKey( 'order.SalesOrder', verbose_name=_('Sales Order Reference'), on_delete=models.SET_NULL, related_name='builds', null=True, blank=True, help_text=_('SalesOrder to which this build is allocated')) take_from = models.ForeignKey( 'stock.StockLocation', verbose_name=_('Source Location'), on_delete=models.SET_NULL, related_name='sourcing_builds', null=True, blank=True, help_text= _('Select location to take stock from for this build (leave blank to take from any stock location)' )) destination = models.ForeignKey( 'stock.StockLocation', verbose_name=_('Destination Location'), on_delete=models.SET_NULL, related_name='incoming_builds', null=True, blank=True, help_text=_( 'Select location where the completed items will be stored'), ) quantity = models.PositiveIntegerField( verbose_name=_('Build Quantity'), default=1, validators=[MinValueValidator(1)], help_text=_('Number of stock items to build')) completed = models.PositiveIntegerField( verbose_name=_('Completed items'), default=0, help_text=_('Number of stock items which have been completed')) status = models.PositiveIntegerField(verbose_name=_('Build Status'), default=BuildStatus.PENDING, choices=BuildStatus.items(), validators=[MinValueValidator(0)], help_text=_('Build status code')) batch = models.CharField(verbose_name=_('Batch Code'), max_length=100, blank=True, null=True, help_text=_('Batch code for this build output')) creation_date = models.DateField(auto_now_add=True, editable=False) target_date = models.DateField( null=True, blank=True, verbose_name=_('Target completion date'), help_text= _('Target date for build completion. Build will be overdue after this date.' )) completion_date = models.DateField(null=True, blank=True) completed_by = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, related_name='builds_completed') issued_by = models.ForeignKey( User, on_delete=models.SET_NULL, blank=True, null=True, help_text=_('User who issued this build order'), related_name='builds_issued', ) responsible = models.ForeignKey( UserModels.Owner, on_delete=models.SET_NULL, blank=True, null=True, help_text=_('User responsible for this build order'), related_name='builds_responsible', ) link = InvenTree.fields.InvenTreeURLField( verbose_name=_('External Link'), blank=True, help_text=_('Link to external URL')) notes = MarkdownxField(verbose_name=_('Notes'), blank=True, help_text=_('Extra build notes')) @property def is_overdue(self): """ Returns true if this build is "overdue": Makes use of the OVERDUE_FILTER to avoid code duplication """ query = Build.objects.filter(pk=self.pk) query = query.filter(Build.OVERDUE_FILTER) return query.exists() @property def active(self): """ Return True if this build is active """ return self.status in BuildStatus.ACTIVE_CODES @property def bom_items(self): """ Returns the BOM items for the part referenced by this BuildOrder """ return self.part.bom_items.all().prefetch_related('sub_part') @property def remaining(self): """ Return the number of outputs remaining to be completed. """ return max(0, self.quantity - self.completed) @property def output_count(self): return self.build_outputs.count() def get_build_outputs(self, **kwargs): """ Return a list of build outputs. kwargs: complete = (True / False) - If supplied, filter by completed status in_stock = (True / False) - If supplied, filter by 'in-stock' status """ outputs = self.build_outputs.all() # Filter by 'in stock' status in_stock = kwargs.get('in_stock', None) if in_stock is not None: if in_stock: outputs = outputs.filter(StockModels.StockItem.IN_STOCK_FILTER) else: outputs = outputs.exclude( StockModels.StockItem.IN_STOCK_FILTER) # Filter by 'complete' status complete = kwargs.get('complete', None) if complete is not None: if complete: outputs = outputs.filter(is_building=False) else: outputs = outputs.filter(is_building=True) return outputs @property def complete_outputs(self): """ Return all the "completed" build outputs """ outputs = self.get_build_outputs(complete=True) # TODO - Ordering? return outputs @property def incomplete_outputs(self): """ Return all the "incomplete" build outputs """ outputs = self.get_build_outputs(complete=False) # TODO - Order by how "complete" they are? return outputs @property def incomplete_count(self): """ Return the total number of "incomplete" outputs """ quantity = 0 for output in self.incomplete_outputs: quantity += output.quantity return quantity @classmethod def getNextBuildNumber(cls): """ Try to predict the next Build Order reference: """ if cls.objects.count() == 0: return None build = cls.objects.last() ref = build.reference if not ref: return None tries = set() while 1: new_ref = increment(ref) if new_ref in tries: # We are potentially stuck in a loop - simply return the original reference return ref if cls.objects.filter(reference=new_ref).exists(): tries.add(new_ref) new_ref = increment(new_ref) else: break return new_ref @property def can_complete(self): """ Returns True if this build can be "completed" - Must not have any outstanding build outputs - 'completed' value must meet (or exceed) the 'quantity' value """ if self.incomplete_count > 0: return False if self.completed < self.quantity: return False # No issues! return True @transaction.atomic def complete_build(self, user): """ Mark this build as complete """ if not self.can_complete: return self.completion_date = datetime.now().date() self.completed_by = user self.status = BuildStatus.COMPLETE self.save() # Ensure that there are no longer any BuildItem objects # which point to thie Build Order self.allocated_stock.all().delete() @transaction.atomic def cancelBuild(self, user): """ Mark the Build as CANCELLED - Delete any pending BuildItem objects (but do not remove items from stock) - Set build status to CANCELLED - Save the Build object """ for item in self.allocated_stock.all(): item.delete() # Date of 'completion' is the date the build was cancelled self.completion_date = datetime.now().date() self.completed_by = user self.status = BuildStatus.CANCELLED self.save() def getAutoAllocations(self, output): """ Return a list of StockItem objects which will be allocated using the 'AutoAllocate' function. For each item in the BOM for the attached Part, the following tests must *all* evaluate to True, for the part to be auto-allocated: - The sub_item in the BOM line must *not* be trackable - There is only a single stock item available (which has not already been allocated to this build) - The stock item has an availability greater than zero Returns: A list object containing the StockItem objects to be allocated (and the quantities). Each item in the list is a dict as follows: { 'stock_item': stock_item, 'quantity': stock_quantity, } """ allocations = [] """ Iterate through each item in the BOM """ for bom_item in self.bom_items: part = bom_item.sub_part # Skip any parts which are already fully allocated if self.isPartFullyAllocated(part, output): continue # How many parts are required to complete the output? required = self.unallocatedQuantity(part, output) # Grab a list of stock items which are available stock_items = self.availableStockItems(part, output) # Ensure that the available stock items are in the correct location if self.take_from is not None: # Filter for stock that is located downstream of the designated location stock_items = stock_items.filter(location__in=[ loc for loc in self.take_from.getUniqueChildren() ]) # Only one StockItem to choose from? Default to that one! if stock_items.count() == 1: stock_item = stock_items[0] # Double check that we have not already allocated this stock-item against this build build_items = BuildItem.objects.filter(build=self, stock_item=stock_item, install_into=output) if len(build_items) > 0: continue # How many items are actually available? if stock_item.quantity > 0: # Only take as many as are available if stock_item.quantity < required: required = stock_item.quantity allocation = { 'stock_item': stock_item, 'quantity': required, } allocations.append(allocation) return allocations @transaction.atomic def unallocateStock(self, output=None, part=None): """ Deletes all stock allocations for this build. Args: output: Specify which build output to delete allocations (optional) """ allocations = BuildItem.objects.filter(build=self.pk) if output: allocations = allocations.filter(install_into=output.pk) if part: allocations = allocations.filter(stock_item__part=part) # Remove all the allocations allocations.delete() @transaction.atomic def create_build_output(self, quantity, **kwargs): """ Create a new build output against this BuildOrder. args: quantity: The quantity of the item to produce kwargs: batch: Override batch code serials: Serial numbers location: Override location """ batch = kwargs.get('batch', self.batch) location = kwargs.get('location', self.destination) serials = kwargs.get('serials', None) """ Determine if we can create a single output (with quantity > 0), or multiple outputs (with quantity = 1) """ multiple = False # Serial numbers are provided? We need to split! if serials: multiple = True # BOM has trackable parts, so we must split! if self.part.has_trackable_parts: multiple = True if multiple: """ Create multiple build outputs with a single quantity of 1 """ for ii in range(quantity): if serials: serial = serials[ii] else: serial = None StockModels.StockItem.objects.create( quantity=1, location=location, part=self.part, build=self, batch=batch, serial=serial, is_building=True, ) else: """ Create a single build output of the given quantity """ StockModels.StockItem.objects.create(quantity=quantity, location=location, part=self.part, build=self, batch=batch, is_building=True) if self.status == BuildStatus.PENDING: self.status = BuildStatus.PRODUCTION self.save() @transaction.atomic def deleteBuildOutput(self, output): """ Remove a build output from the database: - Unallocate any build items against the output - Delete the output StockItem """ if not output: raise ValidationError(_("No build output specified")) if not output.is_building: raise ValidationError(_("Build output is already completed")) if not output.build == self: raise ValidationError(_("Build output does not match Build Order")) # Unallocate all build items against the output self.unallocateStock(output) # Remove the build output from the database output.delete() @transaction.atomic def autoAllocate(self, output): """ Run auto-allocation routine to allocate StockItems to this Build. Args: output: If specified, only auto-allocate against the given built output Returns a list of dict objects with keys like: { 'stock_item': item, 'quantity': quantity, } See: getAutoAllocations() """ allocations = self.getAutoAllocations(output) for item in allocations: # Create a new allocation build_item = BuildItem( build=self, stock_item=item['stock_item'], quantity=item['quantity'], install_into=output, ) build_item.save() @transaction.atomic def completeBuildOutput(self, output, user, **kwargs): """ Complete a particular build output - Remove allocated StockItems - Mark the output as complete """ # Select the location for the build output location = kwargs.get('location', self.destination) # List the allocated BuildItem objects for the given output allocated_items = output.items_to_install.all() for build_item in allocated_items: # TODO: This is VERY SLOW as each deletion from the database takes ~1 second to complete # TODO: Use celery / redis to offload the actual object deletion... # REF: https://www.botreetechnologies.com/blog/implementing-celery-using-django-for-background-task-processing # REF: https://code.tutsplus.com/tutorials/using-celery-with-django-for-background-task-processing--cms-28732 # Complete the allocation of stock for that item build_item.complete_allocation(user) # Delete the BuildItem objects from the database allocated_items.all().delete() # Ensure that the output is updated correctly output.build = self output.is_building = False output.location = location output.save() output.addTransactionNote(_('Completed build output'), user, system=True) # Increase the completed quantity for this build self.completed += output.quantity self.save() def requiredQuantity(self, part, output): """ Get the quantity of a part required to complete the particular build output. Args: part: The Part object output - The particular build output (StockItem) """ # Extract the BOM line item from the database try: bom_item = PartModels.BomItem.objects.get(part=self.part.pk, sub_part=part.pk) quantity = bom_item.quantity except (PartModels.BomItem.DoesNotExist): quantity = 0 if output: quantity *= output.quantity else: quantity *= self.remaining return quantity def allocatedItems(self, part, output): """ Return all BuildItem objects which allocate stock of <part> to <output> Args: part - The part object output - Build output (StockItem). """ allocations = BuildItem.objects.filter( build=self, stock_item__part=part, install_into=output, ) return allocations def allocatedQuantity(self, part, output): """ Return the total quantity of given part allocated to a given build output. """ allocations = self.allocatedItems(part, output) allocated = allocations.aggregate(q=Coalesce(Sum('quantity'), 0)) return allocated['q'] def unallocatedQuantity(self, part, output): """ Return the total unallocated (remaining) quantity of a part against a particular output. """ required = self.requiredQuantity(part, output) allocated = self.allocatedQuantity(part, output) return max(required - allocated, 0) def isPartFullyAllocated(self, part, output): """ Returns True if the part has been fully allocated to the particular build output """ return self.unallocatedQuantity(part, output) == 0 def isFullyAllocated(self, output): """ Returns True if the particular build output is fully allocated. """ for bom_item in self.bom_items: part = bom_item.sub_part if not self.isPartFullyAllocated(part, output): return False # All parts must be fully allocated! return True def allocatedParts(self, output): """ Return a list of parts which have been fully allocated against a particular output """ allocated = [] for bom_item in self.bom_items: part = bom_item.sub_part if self.isPartFullyAllocated(part, output): allocated.append(part) return allocated def unallocatedParts(self, output): """ Return a list of parts which have *not* been fully allocated against a particular output """ unallocated = [] for bom_item in self.bom_items: part = bom_item.sub_part if not self.isPartFullyAllocated(part, output): unallocated.append(part) return unallocated @property def required_parts(self): """ Returns a dict of parts required to build this part (BOM) """ parts = [] for item in self.part.bom_items.all().prefetch_related('sub_part'): parts.append(item.sub_part) return parts def availableStockItems(self, part, output): """ Returns stock items which are available for allocation to this build. Args: part - Part object output - The particular build output """ # Grab initial query for items which are "in stock" and match the part items = StockModels.StockItem.objects.filter( StockModels.StockItem.IN_STOCK_FILTER) items = items.filter(part=part) # Exclude any items which have already been allocated allocated = BuildItem.objects.filter( build=self, stock_item__part=part, install_into=output, ) items = items.exclude( id__in=[item.stock_item.id for item in allocated.all()]) # Limit query to stock items which are "downstream" of the source location if self.take_from is not None: items = items.filter(location__in=[ loc for loc in self.take_from.getUniqueChildren() ]) # Exclude expired stock items if not common.models.InvenTreeSetting.get_setting( 'STOCK_ALLOW_EXPIRED_BUILD'): items = items.exclude(StockModels.StockItem.EXPIRED_FILTER) return items @property def is_active(self): """ Is this build active? An active build is either: - PENDING - HOLDING """ return self.status in BuildStatus.ACTIVE_CODES @property def is_complete(self): """ Returns True if the build status is COMPLETE """ return self.status == BuildStatus.COMPLETE
class Build(MPTTModel): """ A Build object organises the creation of new parts from the component parts. Attributes: part: The part to be built (from component BOM items) title: Brief title describing the build (required) quantity: Number of units to be built parent: Reference to a Build object for which this Build is required 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) take_from: Location to take stock from to make this build (if blank, can take from anywhere) status: Build status code batch: Batch code transferred to build parts (optional) creation_date: Date the build was created (auto) completion_date: Date the build was completed link: External URL for extra information notes: Text notes """ def __str__(self): return "{q} x {part}".format(q=decimal2string(self.quantity), part=str(self.part.full_name)) def get_absolute_url(self): return reverse('build-detail', kwargs={'pk': self.id}) def clean(self): """ Validation for Build object. """ super().clean() try: if self.part.trackable: if not self.quantity == int(self.quantity): raise ValidationError({ 'quantity': _("Build quantity must be integer value for trackable parts" ) }) except PartModels.Part.DoesNotExist: pass title = models.CharField(verbose_name=_('Build Title'), blank=False, max_length=100, help_text=_('Brief description of the build')) parent = TreeForeignKey( 'self', on_delete=models.DO_NOTHING, blank=True, null=True, related_name='children', verbose_name=_('Parent Build'), help_text=_('Parent build to which this build is allocated'), ) part = models.ForeignKey( 'part.Part', verbose_name=_('Part'), on_delete=models.CASCADE, related_name='builds', limit_choices_to={ 'is_template': False, 'assembly': True, 'active': True, 'virtual': False, }, help_text=_('Select part to build'), ) sales_order = models.ForeignKey( 'order.SalesOrder', verbose_name=_('Sales Order Reference'), on_delete=models.SET_NULL, related_name='builds', null=True, blank=True, help_text=_('SalesOrder to which this build is allocated')) take_from = models.ForeignKey( 'stock.StockLocation', verbose_name=_('Source Location'), on_delete=models.SET_NULL, related_name='sourcing_builds', null=True, blank=True, help_text= _('Select location to take stock from for this build (leave blank to take from any stock location)' )) quantity = models.PositiveIntegerField( verbose_name=_('Build Quantity'), default=1, validators=[MinValueValidator(1)], help_text=_('Number of parts to build')) status = models.PositiveIntegerField(verbose_name=_('Build Status'), default=BuildStatus.PENDING, choices=BuildStatus.items(), validators=[MinValueValidator(0)], help_text=_('Build status code')) batch = models.CharField(verbose_name=_('Batch Code'), max_length=100, blank=True, null=True, help_text=_('Batch code for this build output')) creation_date = models.DateField(auto_now_add=True, editable=False) completion_date = models.DateField(null=True, blank=True) completed_by = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, related_name='builds_completed') link = InvenTreeURLField(verbose_name=_('External Link'), blank=True, help_text=_('Link to external URL')) notes = MarkdownxField(verbose_name=_('Notes'), blank=True, help_text=_('Extra build notes')) @property def output_count(self): return self.build_outputs.count() @transaction.atomic def cancelBuild(self, user): """ Mark the Build as CANCELLED - Delete any pending BuildItem objects (but do not remove items from stock) - Set build status to CANCELLED - Save the Build object """ for item in self.allocated_stock.all(): item.delete() # Date of 'completion' is the date the build was cancelled self.completion_date = datetime.now().date() self.completed_by = user self.status = BuildStatus.CANCELLED self.save() def getAutoAllocations(self): """ Return a list of parts which will be allocated using the 'AutoAllocate' function. For each item in the BOM for the attached Part: - If there is a single StockItem, use that StockItem - Take as many parts as available (up to the quantity required for the BOM) - If there are multiple StockItems available, ignore (leave up to the user) Returns: A list object containing the StockItem objects to be allocated (and the quantities) """ allocations = [] for item in self.part.bom_items.all().prefetch_related('sub_part'): # How many parts required for this build? q_required = item.quantity * self.quantity # Grab a list of StockItem objects which are "in stock" stock = StockModels.StockItem.objects.filter( StockModels.StockItem.IN_STOCK_FILTER) # Filter by part reference stock = stock.filter(part=item.sub_part) # Ensure that the available stock items are in the correct location if self.take_from is not None: # Filter for stock that is located downstream of the designated location stock = stock.filter(location__in=[ loc for loc in self.take_from.getUniqueChildren() ]) # Only one StockItem to choose from? Default to that one! if len(stock) == 1: stock_item = stock[0] # Check that we have not already allocated this stock-item against this build build_items = BuildItem.objects.filter(build=self, stock_item=stock_item) if len(build_items) > 0: continue # Are there any parts available? if stock_item.quantity > 0: # Only take as many as are available if stock_item.quantity < q_required: q_required = stock_item.quantity allocation = { 'stock_item': stock_item, 'quantity': q_required, } allocations.append(allocation) return allocations @transaction.atomic def unallocateStock(self): """ Deletes all stock allocations for this build. """ BuildItem.objects.filter(build=self.id).delete() @transaction.atomic def autoAllocate(self): """ Run auto-allocation routine to allocate StockItems to this Build. Returns a list of dict objects with keys like: { 'stock_item': item, 'quantity': quantity, } See: getAutoAllocations() """ allocations = self.getAutoAllocations() for item in allocations: # Create a new allocation build_item = BuildItem(build=self, stock_item=item['stock_item'], quantity=item['quantity']) build_item.save() @transaction.atomic def completeBuild(self, location, serial_numbers, user): """ Mark the Build as COMPLETE - Takes allocated items from stock - Delete pending BuildItem objects """ # Complete the build allocation for each BuildItem for build_item in self.allocated_stock.all().prefetch_related( 'stock_item'): build_item.complete_allocation(user) # Check that the stock-item has been assigned to this build, and remove the builditem from the database if build_item.stock_item.build_order == self: build_item.delete() notes = 'Built {q} on {now}'.format(q=self.quantity, now=str(datetime.now().date())) # Generate the build outputs if self.part.trackable and serial_numbers: # Add new serial numbers for serial in serial_numbers: item = StockModels.StockItem.objects.create( part=self.part, build=self, location=location, quantity=1, serial=serial, batch=str(self.batch) if self.batch else '', notes=notes) item.save() else: # Add stock of the newly created item item = StockModels.StockItem.objects.create( part=self.part, build=self, location=location, quantity=self.quantity, batch=str(self.batch) if self.batch else '', notes=notes) item.save() # Finally, mark the build as complete self.completion_date = datetime.now().date() self.completed_by = user self.status = BuildStatus.COMPLETE self.save() return True def isFullyAllocated(self): """ Return True if this build has been fully allocated. """ bom_items = self.part.bom_items.all() for item in bom_items: part = item.sub_part if not self.isPartFullyAllocated(part): return False return True def isPartFullyAllocated(self, part): """ Check if a given Part is fully allocated for this Build """ return self.getAllocatedQuantity(part) >= self.getRequiredQuantity( part) def getRequiredQuantity(self, part): """ Calculate the quantity of <part> required to make this build. """ try: item = PartModels.BomItem.objects.get(part=self.part.id, sub_part=part.id) q = item.quantity except PartModels.BomItem.DoesNotExist: q = 0 return q * self.quantity def getAllocatedQuantity(self, part): """ Calculate the total number of <part> currently allocated to this build """ allocated = BuildItem.objects.filter( build=self.id, stock_item__part=part.id).aggregate(q=Coalesce(Sum('quantity'), 0)) return allocated['q'] def getUnallocatedQuantity(self, part): """ Calculate the quantity of <part> which still needs to be allocated to this build. Args: Part - the part to be tested Returns: The remaining allocated quantity """ return max( self.getRequiredQuantity(part) - self.getAllocatedQuantity(part), 0) @property def required_parts(self): """ Returns a dict of parts required to build this part (BOM) """ parts = [] for item in self.part.bom_items.all().prefetch_related('sub_part'): part = { 'part': item.sub_part, 'per_build': item.quantity, 'quantity': item.quantity * self.quantity, 'allocated': self.getAllocatedQuantity(item.sub_part) } parts.append(part) return parts @property def can_build(self): """ Return true if there are enough parts to supply build """ for item in self.required_parts: if item['part'].total_stock < item['quantity']: return False return True @property def is_active(self): """ Is this build active? An active build is either: - PENDING - HOLDING """ return self.status in BuildStatus.ACTIVE_CODES @property def is_complete(self): """ Returns True if the build status is COMPLETE """ return self.status == BuildStatus.COMPLETE
class Build(MPTTModel, ReferenceIndexingMixin): """ A Build object organises the creation of new StockItem objects from other existing StockItem objects. Attributes: part: The part to be built (from component BOM items) reference: Build order reference (required, must be unique) title: Brief title describing the build (required) quantity: Number of units to be built parent: Reference to a Build object for which this Build is required 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) take_from: Location to take stock from to make this build (if blank, can take from anywhere) status: Build status code batch: Batch code transferred to build parts (optional) creation_date: Date the build was created (auto) target_date: Date the build will be overdue completion_date: Date the build was completed (or, if incomplete, the expected date of completion) link: External URL for extra information notes: Text notes completed_by: User that completed the build issued_by: User that issued the build responsible: User (or group) responsible for completing the build """ OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q( target_date=None) & Q(target_date__lte=datetime.now().date()) @staticmethod def get_api_url(): return reverse('api-build-list') def api_instance_filters(self): return { 'parent': { 'exclude_tree': self.pk, } } @classmethod def api_defaults(cls, request): """ Return default values for this model when issuing an API OPTIONS request """ defaults = { 'reference': get_next_build_number(), } if request and request.user: defaults['issued_by'] = request.user.pk return defaults def save(self, *args, **kwargs): self.rebuild_reference_field() try: super().save(*args, **kwargs) except InvalidMove: raise ValidationError({ 'parent': _('Invalid choice for parent build'), }) class Meta: verbose_name = _("Build Order") verbose_name_plural = _("Build Orders") def format_barcode(self, **kwargs): """ Return a JSON string to represent this build as a barcode """ return MakeBarcode("buildorder", self.pk, { "reference": self.title, "url": self.get_absolute_url(), }) @staticmethod def filterByDate(queryset, min_date, max_date): """ Filter by 'minimum and maximum date range' - Specified as min_date, max_date - Both must be specified for filter to be applied """ date_fmt = '%Y-%m-%d' # ISO format date string # Ensure that both dates are valid try: min_date = datetime.strptime(str(min_date), date_fmt).date() max_date = datetime.strptime(str(max_date), date_fmt).date() except (ValueError, TypeError): # Date processing error, return queryset unchanged return queryset # Order was completed within the specified range completed = Q(status=BuildStatus.COMPLETE) & Q( completion_date__gte=min_date) & Q(completion_date__lte=max_date) # Order target date falls witin specified range pending = Q( status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q( target_date__gte=min_date) & Q(target_date__lte=max_date) # TODO - Construct a queryset for "overdue" orders queryset = queryset.filter(completed | pending) return queryset def __str__(self): prefix = getSetting("BUILDORDER_REFERENCE_PREFIX") return f"{prefix}{self.reference}" def get_absolute_url(self): return reverse('build-detail', kwargs={'pk': self.id}) reference = models.CharField(unique=True, max_length=64, blank=False, help_text=_('Build Order Reference'), verbose_name=_('Reference'), default=get_next_build_number, validators=[validate_build_order_reference]) title = models.CharField(verbose_name=_('Description'), blank=False, max_length=100, help_text=_('Brief description of the build')) # TODO - Perhaps delete the build "tree" parent = TreeForeignKey( 'self', on_delete=models.SET_NULL, blank=True, null=True, related_name='children', verbose_name=_('Parent Build'), help_text=_('BuildOrder to which this build is allocated'), ) part = models.ForeignKey( 'part.Part', verbose_name=_('Part'), on_delete=models.CASCADE, related_name='builds', limit_choices_to={ 'assembly': True, 'active': True, 'virtual': False, }, help_text=_('Select part to build'), ) sales_order = models.ForeignKey( 'order.SalesOrder', verbose_name=_('Sales Order Reference'), on_delete=models.SET_NULL, related_name='builds', null=True, blank=True, help_text=_('SalesOrder to which this build is allocated')) take_from = models.ForeignKey( 'stock.StockLocation', verbose_name=_('Source Location'), on_delete=models.SET_NULL, related_name='sourcing_builds', null=True, blank=True, help_text= _('Select location to take stock from for this build (leave blank to take from any stock location)' )) destination = models.ForeignKey( 'stock.StockLocation', verbose_name=_('Destination Location'), on_delete=models.SET_NULL, related_name='incoming_builds', null=True, blank=True, help_text=_( 'Select location where the completed items will be stored'), ) quantity = models.PositiveIntegerField( verbose_name=_('Build Quantity'), default=1, validators=[MinValueValidator(1)], help_text=_('Number of stock items to build')) completed = models.PositiveIntegerField( verbose_name=_('Completed items'), default=0, help_text=_('Number of stock items which have been completed')) status = models.PositiveIntegerField(verbose_name=_('Build Status'), default=BuildStatus.PENDING, choices=BuildStatus.items(), validators=[MinValueValidator(0)], help_text=_('Build status code')) batch = models.CharField(verbose_name=_('Batch Code'), max_length=100, blank=True, null=True, help_text=_('Batch code for this build output')) creation_date = models.DateField(auto_now_add=True, editable=False, verbose_name=_('Creation Date')) target_date = models.DateField( null=True, blank=True, verbose_name=_('Target completion date'), help_text= _('Target date for build completion. Build will be overdue after this date.' )) completion_date = models.DateField(null=True, blank=True, verbose_name=_('Completion Date')) completed_by = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('completed by'), related_name='builds_completed') issued_by = models.ForeignKey( User, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Issued by'), help_text=_('User who issued this build order'), related_name='builds_issued', ) responsible = models.ForeignKey( UserModels.Owner, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Responsible'), help_text=_('User responsible for this build order'), related_name='builds_responsible', ) link = InvenTree.fields.InvenTreeURLField( verbose_name=_('External Link'), blank=True, help_text=_('Link to external URL')) notes = MarkdownxField(verbose_name=_('Notes'), blank=True, help_text=_('Extra build notes')) def sub_builds(self, cascade=True): """ Return all Build Order objects under this one. """ if cascade: return Build.objects.filter(parent=self.pk) else: descendants = self.get_descendants(include_self=True) Build.objects.filter(parent__pk__in=[d.pk for d in descendants]) def sub_build_count(self, cascade=True): """ Return the number of sub builds under this one. Args: cascade: If True (defualt), include cascading builds under sub builds """ return self.sub_builds(cascade=cascade).count() @property def is_overdue(self): """ Returns true if this build is "overdue": Makes use of the OVERDUE_FILTER to avoid code duplication """ query = Build.objects.filter(pk=self.pk) query = query.filter(Build.OVERDUE_FILTER) return query.exists() @property def active(self): """ Return True if this build is active """ return self.status in BuildStatus.ACTIVE_CODES @property def bom_items(self): """ Returns the BOM items for the part referenced by this BuildOrder """ return self.part.bom_items.all().prefetch_related('sub_part') @property def tracked_bom_items(self): """ Returns the "trackable" BOM items for this BuildOrder """ items = self.bom_items items = items.filter(sub_part__trackable=True) return items def has_tracked_bom_items(self): """ Returns True if this BuildOrder has trackable BomItems """ return self.tracked_bom_items.count() > 0 @property def untracked_bom_items(self): """ Returns the "non trackable" BOM items for this BuildOrder """ items = self.bom_items items = items.filter(sub_part__trackable=False) return items def has_untracked_bom_items(self): """ Returns True if this BuildOrder has non trackable BomItems """ return self.untracked_bom_items.count() > 0 @property def remaining(self): """ Return the number of outputs remaining to be completed. """ return max(0, self.quantity - self.completed) @property def output_count(self): return self.build_outputs.count() def get_build_outputs(self, **kwargs): """ Return a list of build outputs. kwargs: complete = (True / False) - If supplied, filter by completed status in_stock = (True / False) - If supplied, filter by 'in-stock' status """ outputs = self.build_outputs.all() # Filter by 'in stock' status in_stock = kwargs.get('in_stock', None) if in_stock is not None: if in_stock: outputs = outputs.filter(StockModels.StockItem.IN_STOCK_FILTER) else: outputs = outputs.exclude( StockModels.StockItem.IN_STOCK_FILTER) # Filter by 'complete' status complete = kwargs.get('complete', None) if complete is not None: if complete: outputs = outputs.filter(is_building=False) else: outputs = outputs.filter(is_building=True) return outputs @property def complete_outputs(self): """ Return all the "completed" build outputs """ outputs = self.get_build_outputs(complete=True) # TODO - Ordering? return outputs @property def incomplete_outputs(self): """ Return all the "incomplete" build outputs """ outputs = self.get_build_outputs(complete=False) # TODO - Order by how "complete" they are? return outputs @property def incomplete_count(self): """ Return the total number of "incomplete" outputs """ quantity = 0 for output in self.incomplete_outputs: quantity += output.quantity return quantity @classmethod def getNextBuildNumber(cls): """ Try to predict the next Build Order reference: """ if cls.objects.count() == 0: return None # Extract the "most recent" build order reference builds = cls.objects.exclude(reference=None) if not builds.exists(): return None build = builds.last() ref = build.reference if not ref: return None tries = set(ref) new_ref = ref while 1: new_ref = increment(new_ref) if new_ref in tries: # We are potentially stuck in a loop - simply return the original reference return ref # Check if the existing build reference exists if cls.objects.filter(reference=new_ref).exists(): tries.add(new_ref) else: break return new_ref @property def can_complete(self): """ Returns True if this build can be "completed" - Must not have any outstanding build outputs - 'completed' value must meet (or exceed) the 'quantity' value """ if self.incomplete_count > 0: return False if self.completed < self.quantity: return False if not self.areUntrackedPartsFullyAllocated(): return False # No issues! return True @transaction.atomic def complete_build(self, user): """ Mark this build as complete """ if self.incomplete_count > 0: return self.completion_date = datetime.now().date() self.completed_by = user self.status = BuildStatus.COMPLETE self.save() # Remove untracked allocated stock self.subtractUntrackedStock(user) # Ensure that there are no longer any BuildItem objects # which point to thie Build Order self.allocated_stock.all().delete() @transaction.atomic def cancelBuild(self, user): """ Mark the Build as CANCELLED - Delete any pending BuildItem objects (but do not remove items from stock) - Set build status to CANCELLED - Save the Build object """ for item in self.allocated_stock.all(): item.delete() # Date of 'completion' is the date the build was cancelled self.completion_date = datetime.now().date() self.completed_by = user self.status = BuildStatus.CANCELLED self.save() @transaction.atomic def unallocateStock(self, bom_item=None, output=None): """ Unallocate stock from this Build arguments: - bom_item: Specify a particular BomItem to unallocate stock against - output: Specify a particular StockItem (output) to unallocate stock against """ allocations = BuildItem.objects.filter(build=self, install_into=output) if bom_item: allocations = allocations.filter(bom_item=bom_item) allocations.delete() @transaction.atomic def create_build_output(self, quantity, **kwargs): """ Create a new build output against this BuildOrder. args: quantity: The quantity of the item to produce kwargs: batch: Override batch code serials: Serial numbers location: Override location """ batch = kwargs.get('batch', self.batch) location = kwargs.get('location', self.destination) serials = kwargs.get('serials', None) """ Determine if we can create a single output (with quantity > 0), or multiple outputs (with quantity = 1) """ multiple = False # Serial numbers are provided? We need to split! if serials: multiple = True # BOM has trackable parts, so we must split! if self.part.has_trackable_parts: multiple = True if multiple: """ Create multiple build outputs with a single quantity of 1 """ for ii in range(quantity): if serials: serial = serials[ii] else: serial = None StockModels.StockItem.objects.create( quantity=1, location=location, part=self.part, build=self, batch=batch, serial=serial, is_building=True, ) else: """ Create a single build output of the given quantity """ StockModels.StockItem.objects.create(quantity=quantity, location=location, part=self.part, build=self, batch=batch, is_building=True) if self.status == BuildStatus.PENDING: self.status = BuildStatus.PRODUCTION self.save() @transaction.atomic def deleteBuildOutput(self, output): """ Remove a build output from the database: - Unallocate any build items against the output - Delete the output StockItem """ if not output: raise ValidationError(_("No build output specified")) if not output.is_building: raise ValidationError(_("Build output is already completed")) if not output.build == self: raise ValidationError(_("Build output does not match Build Order")) # Unallocate all build items against the output self.unallocateStock(output=output) # Remove the build output from the database output.delete() @transaction.atomic def subtractUntrackedStock(self, user): """ Called when the Build is marked as "complete", this function removes the allocated untracked items from stock. """ items = self.allocated_stock.filter(stock_item__part__trackable=False) # Remove stock for item in items: item.complete_allocation(user) # Delete allocation items.all().delete() @transaction.atomic def complete_build_output(self, output, user, **kwargs): """ Complete a particular build output - Remove allocated StockItems - Mark the output as complete """ # Select the location for the build output location = kwargs.get('location', self.destination) status = kwargs.get('status', StockStatus.OK) notes = kwargs.get('notes', '') # List the allocated BuildItem objects for the given output allocated_items = output.items_to_install.all() for build_item in allocated_items: # Complete the allocation of stock for that item build_item.complete_allocation(user) # Delete the BuildItem objects from the database allocated_items.all().delete() # Ensure that the output is updated correctly output.build = self output.is_building = False output.location = location output.status = status output.save() output.add_tracking_entry(StockHistoryCode.BUILD_OUTPUT_COMPLETED, user, notes=notes, deltas={ 'status': status, }) # Increase the completed quantity for this build self.completed += output.quantity self.save() def requiredQuantity(self, part, output): """ Get the quantity of a part required to complete the particular build output. Args: part: The Part object output - The particular build output (StockItem) """ # Extract the BOM line item from the database try: bom_item = PartModels.BomItem.objects.get(part=self.part.pk, sub_part=part.pk) quantity = bom_item.quantity except (PartModels.BomItem.DoesNotExist): quantity = 0 if output: quantity *= output.quantity else: quantity *= self.quantity return quantity def allocatedItems(self, part, output): """ Return all BuildItem objects which allocate stock of <part> to <output> Args: part - The part object output - Build output (StockItem). """ # Remember, if 'variant' stock is allowed to be allocated, it becomes more complicated! variants = part.get_descendants(include_self=True) allocations = BuildItem.objects.filter( build=self, stock_item__part__pk__in=[p.pk for p in variants], install_into=output, ) return allocations def allocatedQuantity(self, part, output): """ Return the total quantity of given part allocated to a given build output. """ allocations = self.allocatedItems(part, output) allocated = allocations.aggregate(q=Coalesce( Sum('quantity'), 0, output_field=models.DecimalField(), )) return allocated['q'] def unallocatedQuantity(self, part, output): """ Return the total unallocated (remaining) quantity of a part against a particular output. """ required = self.requiredQuantity(part, output) allocated = self.allocatedQuantity(part, output) return max(required - allocated, 0) def isPartFullyAllocated(self, part, output): """ Returns True if the part has been fully allocated to the particular build output """ return self.unallocatedQuantity(part, output) == 0 def isFullyAllocated(self, output, verbose=False): """ Returns True if the particular build output is fully allocated. """ # If output is not specified, we are talking about "untracked" items if output is None: bom_items = self.untracked_bom_items else: bom_items = self.tracked_bom_items fully_allocated = True for bom_item in bom_items: part = bom_item.sub_part if not self.isPartFullyAllocated(part, output): fully_allocated = False if verbose: print( f"Part {part} is not fully allocated for output {output}" ) else: break # All parts must be fully allocated! return fully_allocated def areUntrackedPartsFullyAllocated(self): """ Returns True if the un-tracked parts are fully allocated for this BuildOrder """ return self.isFullyAllocated(None) def allocatedParts(self, output): """ Return a list of parts which have been fully allocated against a particular output """ allocated = [] # If output is not specified, we are talking about "untracked" items if output is None: bom_items = self.untracked_bom_items else: bom_items = self.tracked_bom_items for bom_item in bom_items: part = bom_item.sub_part if self.isPartFullyAllocated(part, output): allocated.append(part) return allocated def unallocatedParts(self, output): """ Return a list of parts which have *not* been fully allocated against a particular output """ unallocated = [] # If output is not specified, we are talking about "untracked" items if output is None: bom_items = self.untracked_bom_items else: bom_items = self.tracked_bom_items for bom_item in bom_items: part = bom_item.sub_part if not self.isPartFullyAllocated(part, output): unallocated.append(part) return unallocated @property def required_parts(self): """ Returns a list of parts required to build this part (BOM) """ parts = [] for item in self.bom_items: parts.append(item.sub_part) return parts @property def required_parts_to_complete_build(self): """ Returns a list of parts required to complete the full build """ parts = [] for bom_item in self.bom_items: # Get remaining quantity needed required_quantity_to_complete_build = self.remaining * bom_item.quantity # Compare to net stock if bom_item.sub_part.net_stock < required_quantity_to_complete_build: parts.append(bom_item.sub_part) return parts def availableStockItems(self, part, output): """ Returns stock items which are available for allocation to this build. Args: part - Part object output - The particular build output """ # Grab initial query for items which are "in stock" and match the part items = StockModels.StockItem.objects.filter( StockModels.StockItem.IN_STOCK_FILTER) # Check if variants are allowed for this part try: bom_item = PartModels.BomItem.objects.get(part=self.part, sub_part=part) allow_part_variants = bom_item.allow_variants except PartModels.BomItem.DoesNotExist: allow_part_variants = False if allow_part_variants: parts = part.get_descendants(include_self=True) items = items.filter(part__pk__in=[p.pk for p in parts]) else: items = items.filter(part=part) # Exclude any items which have already been allocated allocated = BuildItem.objects.filter( build=self, stock_item__part=part, install_into=output, ) items = items.exclude( id__in=[item.stock_item.id for item in allocated.all()]) # Limit query to stock items which are "downstream" of the source location if self.take_from is not None: items = items.filter(location__in=[ loc for loc in self.take_from.getUniqueChildren() ]) # Exclude expired stock items if not common.models.InvenTreeSetting.get_setting( 'STOCK_ALLOW_EXPIRED_BUILD'): items = items.exclude(StockModels.StockItem.EXPIRED_FILTER) return items @property def is_active(self): """ Is this build active? An active build is either: - PENDING - HOLDING """ return self.status in BuildStatus.ACTIVE_CODES @property def is_complete(self): """ Returns True if the build status is COMPLETE """ return self.status == BuildStatus.COMPLETE
class Build(models.Model): """ A Build object organises the creation of new parts from the component parts. Attributes: part: The part to be built (from component BOM items) title: Brief title describing the build (required) quantity: Number of units to be built take_from: Location to take stock from to make this build (if blank, can take from anywhere) status: Build status code batch: Batch code transferred to build parts (optional) creation_date: Date the build was created (auto) completion_date: Date the build was completed URL: External URL for extra information notes: Text notes """ def __str__(self): return "Build {q} x {part}".format(q=self.quantity, part=str(self.part)) def get_absolute_url(self): return reverse('build-detail', kwargs={'pk': self.id}) title = models.CharField(blank=False, max_length=100, help_text='Brief description of the build') part = models.ForeignKey( 'part.Part', on_delete=models.CASCADE, related_name='builds', limit_choices_to={ 'is_template': False, 'assembly': True, 'active': True }, help_text='Select part to build', ) take_from = models.ForeignKey( 'stock.StockLocation', on_delete=models.SET_NULL, related_name='sourcing_builds', null=True, blank=True, help_text= 'Select location to take stock from for this build (leave blank to take from any stock location)' ) quantity = models.PositiveIntegerField( default=1, validators=[MinValueValidator(1)], help_text='Number of parts to build') status = models.PositiveIntegerField(default=BuildStatus.PENDING, choices=BuildStatus.items(), validators=[MinValueValidator(0)], help_text='Build status') batch = models.CharField(max_length=100, blank=True, null=True, help_text='Batch code for this build output') creation_date = models.DateField(auto_now=True, editable=False) completion_date = models.DateField(null=True, blank=True) completed_by = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, related_name='builds_completed') URL = models.URLField(blank=True, help_text='Link to external URL') notes = models.TextField(blank=True, help_text='Extra build notes') """ Notes attached to each build output """ @transaction.atomic def cancelBuild(self, user): """ Mark the Build as CANCELLED - Delete any pending BuildItem objects (but do not remove items from stock) - Set build status to CANCELLED - Save the Build object """ for item in self.allocated_stock.all(): item.delete() # Date of 'completion' is the date the build was cancelled self.completion_date = datetime.now().date() self.completed_by = user self.status = BuildStatus.CANCELLED self.save() def getAutoAllocations(self): """ Return a list of parts which will be allocated using the 'AutoAllocate' function. For each item in the BOM for the attached Part: - If there is a single StockItem, use that StockItem - Take as many parts as available (up to the quantity required for the BOM) - If there are multiple StockItems available, ignore (leave up to the user) Returns: A list object containing the StockItem objects to be allocated (and the quantities) """ allocations = [] for item in self.part.bom_items.all().prefetch_related('sub_part'): # How many parts required for this build? q_required = item.quantity * self.quantity stock = StockItem.objects.filter(part=item.sub_part) # Ensure that the available stock items are in the correct location if self.take_from is not None: # Filter for stock that is located downstream of the designated location stock = stock.filter(location__in=[ loc for loc in self.take_from.getUniqueChildren() ]) # Only one StockItem to choose from? Default to that one! if len(stock) == 1: stock_item = stock[0] # Check that we have not already allocated this stock-item against this build build_items = BuildItem.objects.filter(build=self, stock_item=stock_item) if len(build_items) > 0: continue # Are there any parts available? if stock_item.quantity > 0: # Only take as many as are available if stock_item.quantity < q_required: q_required = stock_item.quantity allocation = { 'stock_item': stock_item, 'quantity': q_required, } allocations.append(allocation) return allocations @transaction.atomic def unallocateStock(self): """ Deletes all stock allocations for this build. """ BuildItem.objects.filter(build=self.id).delete() @transaction.atomic def autoAllocate(self): """ Run auto-allocation routine to allocate StockItems to this Build. Returns a list of dict objects with keys like: { 'stock_item': item, 'quantity': quantity, } See: getAutoAllocations() """ allocations = self.getAutoAllocations() for item in allocations: # Create a new allocation build_item = BuildItem(build=self, stock_item=item['stock_item'], quantity=item['quantity']) build_item.save() @transaction.atomic def completeBuild(self, location, serial_numbers, user): """ Mark the Build as COMPLETE - Takes allocated items from stock - Delete pending BuildItem objects """ for item in self.allocated_stock.all().prefetch_related('stock_item'): # Subtract stock from the item item.stock_item.take_stock( item.quantity, user, 'Removed {n} items to build {m} x {part}'.format( n=item.quantity, m=self.quantity, part=self.part.full_name)) # Delete the item item.delete() # Mark the date of completion self.completion_date = datetime.now().date() self.completed_by = user notes = 'Built {q} on {now}'.format(q=self.quantity, now=str(datetime.now().date())) if self.part.trackable: # Add new serial numbers for serial in serial_numbers: item = StockItem.objects.create( part=self.part, location=location, quantity=1, serial=serial, batch=str(self.batch) if self.batch else '', notes=notes) item.save() else: # Add stock of the newly created item item = StockItem.objects.create( part=self.part, location=location, quantity=self.quantity, batch=str(self.batch) if self.batch else '', notes=notes) item.save() # Finally, mark the build as complete self.status = BuildStatus.COMPLETE self.save() def getRequiredQuantity(self, part): """ Calculate the quantity of <part> required to make this build. """ try: item = BomItem.objects.get(part=self.part.id, sub_part=part.id) return item.get_required_quantity(self.quantity) except BomItem.DoesNotExist: return 0 def getAllocatedQuantity(self, part): """ Calculate the total number of <part> currently allocated to this build """ allocated = BuildItem.objects.filter( build=self.id, stock_item__part=part.id).aggregate(Sum('quantity')) q = allocated['quantity__sum'] if q: return int(q) else: return 0 def getUnallocatedQuantity(self, part): """ Calculate the quantity of <part> which still needs to be allocated to this build. Args: Part - the part to be tested Returns: The remaining allocated quantity """ return max( self.getRequiredQuantity(part) - self.getAllocatedQuantity(part), 0) @property def required_parts(self): """ Returns a dict of parts required to build this part (BOM) """ parts = [] for item in self.part.bom_items.all().prefetch_related('sub_part'): part = { 'part': item.sub_part, 'per_build': item.quantity, 'quantity': item.quantity * self.quantity, 'allocated': self.getAllocatedQuantity(item.sub_part) } parts.append(part) return parts @property def can_build(self): """ Return true if there are enough parts to supply build """ for item in self.required_parts: if item['part'].total_stock < item['quantity']: return False return True @property def is_active(self): """ Is this build active? An active build is either: - PENDING - HOLDING """ return self.status in BuildStatus.ACTIVE_CODES @property def is_complete(self): """ Returns True if the build status is COMPLETE """ return self.status == BuildStatus.COMPLETE
class Build(MPTTModel, ReferenceIndexingMixin): """A Build object organises the creation of new StockItem objects from other existing StockItem objects. Attributes: part: The part to be built (from component BOM items) reference: Build order reference (required, must be unique) title: Brief title describing the build (required) quantity: Number of units to be built parent: Reference to a Build object for which this Build is required 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) take_from: Location to take stock from to make this build (if blank, can take from anywhere) status: Build status code batch: Batch code transferred to build parts (optional) creation_date: Date the build was created (auto) target_date: Date the build will be overdue completion_date: Date the build was completed (or, if incomplete, the expected date of completion) link: External URL for extra information notes: Text notes completed_by: User that completed the build issued_by: User that issued the build responsible: User (or group) responsible for completing the build """ OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q( target_date=None) & Q(target_date__lte=datetime.now().date()) # Global setting for specifying reference pattern REFERENCE_PATTERN_SETTING = 'BUILDORDER_REFERENCE_PATTERN' @staticmethod def get_api_url(): """Return the API URL associated with the BuildOrder model""" return reverse('api-build-list') def api_instance_filters(self): """Returns custom API filters for the particular BuildOrder instance""" return { 'parent': { 'exclude_tree': self.pk, } } @classmethod def api_defaults(cls, request): """Return default values for this model when issuing an API OPTIONS request.""" defaults = { 'reference': generate_next_build_reference(), } if request and request.user: defaults['issued_by'] = request.user.pk return defaults def save(self, *args, **kwargs): """Custom save method for the BuildOrder model""" self.validate_reference_field(self.reference) self.reference_int = self.rebuild_reference_field(self.reference) try: super().save(*args, **kwargs) except InvalidMove: raise ValidationError({ 'parent': _('Invalid choice for parent build'), }) class Meta: """Metaclass options for the BuildOrder model""" verbose_name = _("Build Order") verbose_name_plural = _("Build Orders") def format_barcode(self, **kwargs): """Return a JSON string to represent this build as a barcode.""" return MakeBarcode("buildorder", self.pk, { "reference": self.title, "url": self.get_absolute_url(), }) @staticmethod def filterByDate(queryset, min_date, max_date): """Filter by 'minimum and maximum date range'. - Specified as min_date, max_date - Both must be specified for filter to be applied """ date_fmt = '%Y-%m-%d' # ISO format date string # Ensure that both dates are valid try: min_date = datetime.strptime(str(min_date), date_fmt).date() max_date = datetime.strptime(str(max_date), date_fmt).date() except (ValueError, TypeError): # Date processing error, return queryset unchanged return queryset # Order was completed within the specified range completed = Q(status=BuildStatus.COMPLETE) & Q( completion_date__gte=min_date) & Q(completion_date__lte=max_date) # Order target date falls witin specified range pending = Q( status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q( target_date__gte=min_date) & Q(target_date__lte=max_date) # TODO - Construct a queryset for "overdue" orders queryset = queryset.filter(completed | pending) return queryset def __str__(self): """String representation of a BuildOrder""" return self.reference def get_absolute_url(self): """Return the web URL associated with this BuildOrder""" return reverse('build-detail', kwargs={'pk': self.id}) reference = models.CharField(unique=True, max_length=64, blank=False, help_text=_('Build Order Reference'), verbose_name=_('Reference'), default=generate_next_build_reference, validators=[ validate_build_order_reference, ]) title = models.CharField(verbose_name=_('Description'), blank=False, max_length=100, help_text=_('Brief description of the build')) parent = TreeForeignKey( 'self', on_delete=models.SET_NULL, blank=True, null=True, related_name='children', verbose_name=_('Parent Build'), help_text=_('BuildOrder to which this build is allocated'), ) part = models.ForeignKey( 'part.Part', verbose_name=_('Part'), on_delete=models.CASCADE, related_name='builds', limit_choices_to={ 'assembly': True, 'active': True, 'virtual': False, }, help_text=_('Select part to build'), ) sales_order = models.ForeignKey( 'order.SalesOrder', verbose_name=_('Sales Order Reference'), on_delete=models.SET_NULL, related_name='builds', null=True, blank=True, help_text=_('SalesOrder to which this build is allocated')) take_from = models.ForeignKey( 'stock.StockLocation', verbose_name=_('Source Location'), on_delete=models.SET_NULL, related_name='sourcing_builds', null=True, blank=True, help_text= _('Select location to take stock from for this build (leave blank to take from any stock location)' )) destination = models.ForeignKey( 'stock.StockLocation', verbose_name=_('Destination Location'), on_delete=models.SET_NULL, related_name='incoming_builds', null=True, blank=True, help_text=_( 'Select location where the completed items will be stored'), ) quantity = models.PositiveIntegerField( verbose_name=_('Build Quantity'), default=1, validators=[MinValueValidator(1)], help_text=_('Number of stock items to build')) completed = models.PositiveIntegerField( verbose_name=_('Completed items'), default=0, help_text=_('Number of stock items which have been completed')) status = models.PositiveIntegerField(verbose_name=_('Build Status'), default=BuildStatus.PENDING, choices=BuildStatus.items(), validators=[MinValueValidator(0)], help_text=_('Build status code')) batch = models.CharField(verbose_name=_('Batch Code'), max_length=100, blank=True, null=True, help_text=_('Batch code for this build output')) creation_date = models.DateField(auto_now_add=True, editable=False, verbose_name=_('Creation Date')) target_date = models.DateField( null=True, blank=True, verbose_name=_('Target completion date'), help_text= _('Target date for build completion. Build will be overdue after this date.' )) completion_date = models.DateField(null=True, blank=True, verbose_name=_('Completion Date')) completed_by = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('completed by'), related_name='builds_completed') issued_by = models.ForeignKey( User, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Issued by'), help_text=_('User who issued this build order'), related_name='builds_issued', ) responsible = models.ForeignKey( UserModels.Owner, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Responsible'), help_text=_('User responsible for this build order'), related_name='builds_responsible', ) link = InvenTree.fields.InvenTreeURLField( verbose_name=_('External Link'), blank=True, help_text=_('Link to external URL')) notes = InvenTree.fields.InvenTreeNotesField( help_text=_('Extra build notes')) def sub_builds(self, cascade=True): """Return all Build Order objects under this one.""" if cascade: return Build.objects.filter(parent=self.pk) else: descendants = self.get_descendants(include_self=True) Build.objects.filter(parent__pk__in=[d.pk for d in descendants]) def sub_build_count(self, cascade=True): """Return the number of sub builds under this one. Args: cascade: If True (defualt), include cascading builds under sub builds """ return self.sub_builds(cascade=cascade).count() @property def is_overdue(self): """Returns true if this build is "overdue". Makes use of the OVERDUE_FILTER to avoid code duplication Returns: bool: Is the build overdue """ query = Build.objects.filter(pk=self.pk) query = query.filter(Build.OVERDUE_FILTER) return query.exists() @property def active(self): """Return True if this build is active.""" return self.status in BuildStatus.ACTIVE_CODES @property def bom_items(self): """Returns the BOM items for the part referenced by this BuildOrder.""" return self.part.get_bom_items() @property def tracked_bom_items(self): """Returns the "trackable" BOM items for this BuildOrder.""" items = self.bom_items items = items.filter(sub_part__trackable=True) return items def has_tracked_bom_items(self): """Returns True if this BuildOrder has trackable BomItems.""" return self.tracked_bom_items.count() > 0 @property def untracked_bom_items(self): """Returns the "non trackable" BOM items for this BuildOrder.""" items = self.bom_items items = items.filter(sub_part__trackable=False) return items def has_untracked_bom_items(self): """Returns True if this BuildOrder has non trackable BomItems.""" return self.untracked_bom_items.count() > 0 @property def remaining(self): """Return the number of outputs remaining to be completed.""" return max(0, self.quantity - self.completed) @property def output_count(self): """Return the number of build outputs (StockItem) associated with this build order""" return self.build_outputs.count() def has_build_outputs(self): """Returns True if this build has more than zero build outputs""" return self.output_count > 0 def get_build_outputs(self, **kwargs): """Return a list of build outputs. kwargs: complete = (True / False) - If supplied, filter by completed status in_stock = (True / False) - If supplied, filter by 'in-stock' status """ outputs = self.build_outputs.all() # Filter by 'in stock' status in_stock = kwargs.get('in_stock', None) if in_stock is not None: if in_stock: outputs = outputs.filter(StockModels.StockItem.IN_STOCK_FILTER) else: outputs = outputs.exclude( StockModels.StockItem.IN_STOCK_FILTER) # Filter by 'complete' status complete = kwargs.get('complete', None) if complete is not None: if complete: outputs = outputs.filter(is_building=False) else: outputs = outputs.filter(is_building=True) return outputs @property def complete_outputs(self): """Return all the "completed" build outputs.""" outputs = self.get_build_outputs(complete=True) return outputs @property def complete_count(self): """Return the total quantity of completed outputs""" quantity = 0 for output in self.complete_outputs: quantity += output.quantity return quantity @property def incomplete_outputs(self): """Return all the "incomplete" build outputs.""" outputs = self.get_build_outputs(complete=False) return outputs @property def incomplete_count(self): """Return the total number of "incomplete" outputs.""" quantity = 0 for output in self.incomplete_outputs: quantity += output.quantity return quantity @classmethod def getNextBuildNumber(cls): """Try to predict the next Build Order reference.""" if cls.objects.count() == 0: return None # Extract the "most recent" build order reference builds = cls.objects.exclude(reference=None) if not builds.exists(): return None build = builds.last() ref = build.reference if not ref: return None tries = set(ref) new_ref = ref while 1: new_ref = increment(new_ref) if new_ref in tries: # We are potentially stuck in a loop - simply return the original reference return ref # Check if the existing build reference exists if cls.objects.filter(reference=new_ref).exists(): tries.add(new_ref) else: break return new_ref @property def can_complete(self): """Returns True if this build can be "completed". - Must not have any outstanding build outputs - 'completed' value must meet (or exceed) the 'quantity' value """ if self.incomplete_count > 0: return False if self.remaining > 0: return False if not self.are_untracked_parts_allocated(): return False # No issues! return True @transaction.atomic def complete_build(self, user): """Mark this build as complete.""" if self.incomplete_count > 0: return self.completion_date = datetime.now().date() self.completed_by = user self.status = BuildStatus.COMPLETE self.save() # Remove untracked allocated stock self.subtract_allocated_stock(user) # Ensure that there are no longer any BuildItem objects # which point to this Build Order self.allocated_stock.all().delete() # Register an event trigger_event('build.completed', id=self.pk) # Notify users that this build has been completed targets = [ self.issued_by, self.responsible, ] # Notify those users interested in the parent build if self.parent: targets.append(self.parent.issued_by) targets.append(self.parent.responsible) # Notify users if this build points to a sales order if self.sales_order: targets.append(self.sales_order.created_by) targets.append(self.sales_order.responsible) build = self name = _(f'Build order {build} has been completed') context = { 'build': build, 'name': name, 'slug': 'build.completed', 'message': _('A build order has been completed'), 'link': InvenTree.helpers.construct_absolute_url(self.get_absolute_url()), 'template': { 'html': 'email/build_order_completed.html', 'subject': name, } } common.notifications.trigger_notification( build, 'build.completed', targets=targets, context=context, target_exclude=[user], ) @transaction.atomic def cancel_build(self, user, **kwargs): """Mark the Build as CANCELLED. - Delete any pending BuildItem objects (but do not remove items from stock) - Set build status to CANCELLED - Save the Build object """ remove_allocated_stock = kwargs.get('remove_allocated_stock', False) remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False) # Handle stock allocations for build_item in self.allocated_stock.all(): if remove_allocated_stock: build_item.complete_allocation(user) build_item.delete() # Remove incomplete outputs (if required) if remove_incomplete_outputs: outputs = self.build_outputs.filter(is_building=True) for output in outputs: output.delete() # Date of 'completion' is the date the build was cancelled self.completion_date = datetime.now().date() self.completed_by = user self.status = BuildStatus.CANCELLED self.save() trigger_event('build.cancelled', id=self.pk) @transaction.atomic def unallocateStock(self, bom_item=None, output=None): """Unallocate stock from this Build. Args: bom_item: Specify a particular BomItem to unallocate stock against output: Specify a particular StockItem (output) to unallocate stock against """ allocations = BuildItem.objects.filter(build=self, install_into=output) if bom_item: allocations = allocations.filter(bom_item=bom_item) allocations.delete() @transaction.atomic def create_build_output(self, quantity, **kwargs): """Create a new build output against this BuildOrder. Args: quantity: The quantity of the item to produce Kwargs: batch: Override batch code serials: Serial numbers location: Override location auto_allocate: Automatically allocate stock with matching serial numbers """ batch = kwargs.get('batch', self.batch) location = kwargs.get('location', self.destination) serials = kwargs.get('serials', None) auto_allocate = kwargs.get('auto_allocate', False) """ Determine if we can create a single output (with quantity > 0), or multiple outputs (with quantity = 1) """ multiple = False # Serial numbers are provided? We need to split! if serials: multiple = True # BOM has trackable parts, so we must split! if self.part.has_trackable_parts: multiple = True if multiple: """Create multiple build outputs with a single quantity of 1.""" # Quantity *must* be an integer at this point! quantity = int(quantity) for ii in range(quantity): if serials: serial = serials[ii] else: serial = None output = StockModels.StockItem.objects.create( quantity=1, location=location, part=self.part, build=self, batch=batch, serial=serial, is_building=True, ) if auto_allocate and serial is not None: # Get a list of BomItem objects which point to "trackable" parts for bom_item in self.part.get_trackable_parts(): parts = bom_item.get_valid_parts_for_allocation() for part in parts: items = StockModels.StockItem.objects.filter( part=part, serial=str(serial), quantity=1, ).filter(StockModels.StockItem.IN_STOCK_FILTER) """ Test if there is a matching serial number! """ if items.exists() and items.count() == 1: stock_item = items[0] # Allocate the stock item BuildItem.objects.create( build=self, bom_item=bom_item, stock_item=stock_item, quantity=1, install_into=output, ) else: """Create a single build output of the given quantity.""" StockModels.StockItem.objects.create(quantity=quantity, location=location, part=self.part, build=self, batch=batch, is_building=True) if self.status == BuildStatus.PENDING: self.status = BuildStatus.PRODUCTION self.save() @transaction.atomic def delete_output(self, output): """Remove a build output from the database. Executes: - Unallocate any build items against the output - Delete the output StockItem """ if not output: raise ValidationError(_("No build output specified")) if not output.is_building: raise ValidationError(_("Build output is already completed")) if output.build != self: raise ValidationError(_("Build output does not match Build Order")) # Unallocate all build items against the output self.unallocateStock(output=output) # Remove the build output from the database output.delete() @transaction.atomic def subtract_allocated_stock(self, user): """Called when the Build is marked as "complete", this function removes the allocated untracked items from stock.""" items = self.allocated_stock.filter(stock_item__part__trackable=False) # Remove stock for item in items: item.complete_allocation(user) # Delete allocation items.all().delete() @transaction.atomic def complete_build_output(self, output, user, **kwargs): """Complete a particular build output. - Remove allocated StockItems - Mark the output as complete """ # Select the location for the build output location = kwargs.get('location', self.destination) status = kwargs.get('status', StockStatus.OK) notes = kwargs.get('notes', '') # List the allocated BuildItem objects for the given output allocated_items = output.items_to_install.all() for build_item in allocated_items: # Complete the allocation of stock for that item build_item.complete_allocation(user) # Delete the BuildItem objects from the database allocated_items.all().delete() # Ensure that the output is updated correctly output.build = self output.is_building = False output.location = location output.status = status output.save() output.add_tracking_entry(StockHistoryCode.BUILD_OUTPUT_COMPLETED, user, notes=notes, deltas={ 'status': status, }) # Increase the completed quantity for this build self.completed += output.quantity self.save() @transaction.atomic def auto_allocate_stock(self, **kwargs): """Automatically allocate stock items against this build order. Following a number of 'guidelines': - Only "untracked" BOM items are considered (tracked BOM items must be manually allocated) - If a particular BOM item is already fully allocated, it is skipped - Extract all available stock items for the BOM part - If variant stock is allowed, extract stock for those too - If substitute parts are available, extract stock for those also - If a single stock item is found, we can allocate that and move on! - If multiple stock items are found, we *may* be able to allocate: - If the calling function has specified that items are interchangeable """ location = kwargs.get('location', None) exclude_location = kwargs.get('exclude_location', None) interchangeable = kwargs.get('interchangeable', False) substitutes = kwargs.get('substitutes', True) def stock_sort(item, bom_item, variant_parts): if item.part == bom_item.sub_part: return 1 elif item.part in variant_parts: return 2 else: return 3 # Get a list of all 'untracked' BOM items for bom_item in self.untracked_bom_items: variant_parts = bom_item.sub_part.get_descendants( include_self=False) unallocated_quantity = self.unallocated_quantity(bom_item) if unallocated_quantity <= 0: # This BomItem is fully allocated, we can continue continue # Check which parts we can "use" (may include variants and substitutes) available_parts = bom_item.get_valid_parts_for_allocation( allow_variants=True, allow_substitutes=substitutes, ) # Look for available stock items available_stock = StockModels.StockItem.objects.filter( StockModels.StockItem.IN_STOCK_FILTER) # Filter by list of available parts available_stock = available_stock.filter( part__in=[p for p in available_parts], ) # Filter out "serialized" stock items, these cannot be auto-allocated available_stock = available_stock.filter( Q(serial=None) | Q(serial='')) if location: # Filter only stock items located "below" the specified location sublocations = location.get_descendants(include_self=True) available_stock = available_stock.filter( location__in=[loc for loc in sublocations]) if exclude_location: # Exclude any stock items from the provided location sublocations = exclude_location.get_descendants( include_self=True) available_stock = available_stock.exclude( location__in=[loc for loc in sublocations]) """ Next, we sort the available stock items with the following priority: 1. Direct part matches (+1) 2. Variant part matches (+2) 3. Substitute part matches (+3) This ensures that allocation priority is first given to "direct" parts """ available_stock = sorted(available_stock, key=lambda item, b=bom_item, v= variant_parts: stock_sort(item, b, v)) if len(available_stock) == 0: # No stock items are available continue elif len(available_stock) == 1 or interchangeable: # Either there is only a single stock item available, # or all items are "interchangeable" and we don't care where we take stock from for stock_item in available_stock: # How much of the stock item is "available" for allocation? quantity = min(unallocated_quantity, stock_item.unallocated_quantity()) if quantity > 0: try: BuildItem.objects.create( build=self, bom_item=bom_item, stock_item=stock_item, quantity=quantity, ) # Subtract the required quantity unallocated_quantity -= quantity except (ValidationError, serializers.ValidationError) as exc: # Catch model errors and re-throw as DRF errors raise ValidationError( detail=serializers.as_serializer_error(exc)) if unallocated_quantity <= 0: # We have now fully-allocated this BomItem - no need to continue! break def required_quantity(self, bom_item, output=None): """Get the quantity of a part required to complete the particular build output. Args: bom_item: The Part object output: The particular build output (StockItem) """ quantity = bom_item.quantity if output: quantity *= output.quantity else: quantity *= self.quantity return quantity def allocated_bom_items(self, bom_item, output=None): """Return all BuildItem objects which allocate stock of <bom_item> to <output>. Note that the bom_item may allow variants, or direct substitutes, making things difficult. Args: bom_item: The BomItem object output: Build output (StockItem). """ allocations = BuildItem.objects.filter( build=self, bom_item=bom_item, install_into=output, ) return allocations def allocated_quantity(self, bom_item, output=None): """Return the total quantity of given part allocated to a given build output.""" allocations = self.allocated_bom_items(bom_item, output) allocated = allocations.aggregate(q=Coalesce( Sum('quantity'), 0, output_field=models.DecimalField(), )) return allocated['q'] def unallocated_quantity(self, bom_item, output=None): """Return the total unallocated (remaining) quantity of a part against a particular output.""" required = self.required_quantity(bom_item, output) allocated = self.allocated_quantity(bom_item, output) return max(required - allocated, 0) def is_bom_item_allocated(self, bom_item, output=None): """Test if the supplied BomItem has been fully allocated!""" return self.unallocated_quantity(bom_item, output) == 0 def is_fully_allocated(self, output): """Returns True if the particular build output is fully allocated.""" # If output is not specified, we are talking about "untracked" items if output is None: bom_items = self.untracked_bom_items else: bom_items = self.tracked_bom_items for bom_item in bom_items: if not self.is_bom_item_allocated(bom_item, output): return False # All parts must be fully allocated! return True def is_partially_allocated(self, output): """Returns True if the particular build output is (at least) partially allocated.""" # If output is not specified, we are talking about "untracked" items if output is None: bom_items = self.untracked_bom_items else: bom_items = self.tracked_bom_items for bom_item in bom_items: if self.allocated_quantity(bom_item, output) > 0: return True return False def are_untracked_parts_allocated(self): """Returns True if the un-tracked parts are fully allocated for this BuildOrder.""" return self.is_fully_allocated(None) def has_overallocated_parts(self, output=None): """Check if parts have been 'over-allocated' against the specified output. Note: If output=None, test un-tracked parts """ bom_items = self.tracked_bom_items if output else self.untracked_bom_items for bom_item in bom_items: if self.allocated_quantity(bom_item, output) > self.required_quantity( bom_item, output): return True return False def unallocated_bom_items(self, output): """Return a list of bom items which have *not* been fully allocated against a particular output.""" unallocated = [] # If output is not specified, we are talking about "untracked" items if output is None: bom_items = self.untracked_bom_items else: bom_items = self.tracked_bom_items for bom_item in bom_items: if not self.is_bom_item_allocated(bom_item, output): unallocated.append(bom_item) return unallocated @property def required_parts(self): """Returns a list of parts required to build this part (BOM).""" parts = [] for item in self.bom_items: parts.append(item.sub_part) return parts @property def required_parts_to_complete_build(self): """Returns a list of parts required to complete the full build.""" parts = [] for bom_item in self.bom_items: # Get remaining quantity needed required_quantity_to_complete_build = self.remaining * bom_item.quantity # Compare to net stock if bom_item.sub_part.net_stock < required_quantity_to_complete_build: parts.append(bom_item.sub_part) return parts @property def is_active(self): """Is this build active? An active build is either: - PENDING - HOLDING """ return self.status in BuildStatus.ACTIVE_CODES @property def is_complete(self): """Returns True if the build status is COMPLETE.""" return self.status == BuildStatus.COMPLETE