""" Build database model definitions """ # -*- coding: utf-8 -*- from __future__ import unicode_literals from datetime import datetime from django.contrib.auth.models import User from django.utils.translation import ugettext as _ from django.core.exceptions import ValidationError from django.urls import reverse from django.db import models, transaction from django.db.models import Sum from django.core.validators import MinValueValidator from InvenTree.status_codes import BuildStatus from stock.models import StockItem from part.models import Part, BomItem 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, 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 = 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.status = BuildStatus.COMPLETE self.save() def getRequiredQuantity(self, part): """ Calculate the quantity of 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 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 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 BuildItem(models.Model): """ A BuildItem links multiple StockItem objects to a Build. 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. Attributes: build: Link to a Build object stock_item: Link to a StockItem object quantity: Number of units allocated """ def get_absolute_url(self): # TODO - Fix! return '/build/item/{pk}/'.format(pk=self.id) # return reverse('build-detail', kwargs={'pk': self.id}) class Meta: unique_together = [ ('build', 'stock_item'), ] def clean(self): """ Check validity of the BuildItem model. The following checks are performed: - StockItem.part must be in the BOM of the Part object referenced by Build - Allocation quantity cannot exceed available quantity """ super(BuildItem, self).clean() errors = {} try: if self.stock_item.part not in self.build.part.required_parts(): errors['stock_item'] = [_("Selected stock item not found in BOM for part '{p}'".format(p=self.build.part.full_name))] if self.quantity > self.stock_item.quantity: errors['quantity'] = [_("Allocated quantity ({n}) must not exceed available quantity ({q})".format( n=self.quantity, q=self.stock_item.quantity ))] except StockItem.DoesNotExist: pass except Part.DoesNotExist: pass if len(errors) > 0: raise ValidationError(errors) build = models.ForeignKey( Build, on_delete=models.CASCADE, related_name='allocated_stock', help_text='Build to allocate parts' ) stock_item = models.ForeignKey( 'stock.StockItem', on_delete=models.CASCADE, related_name='allocations', help_text='Stock Item to allocate to build', ) quantity = models.PositiveIntegerField( default=1, validators=[MinValueValidator(1)], help_text='Stock quantity to allocate to build' )