Ejemplo n.º 1
0
def load_status_codes(context):
    """
    Make the various StatusCodes available to the page context
    """

    context['order_status_codes'] = OrderStatus.list()
    context['stock_status_codes'] = StockStatus.list()
    context['build_status_codes'] = BuildStatus.list()

    # Need to return something as the result is rendered to the page
    return ''
Ejemplo n.º 2
0
def build_status_label(key, *args, **kwargs):
    """ Render a Build status label """
    return mark_safe(BuildStatus.render(key, large=kwargs.get('large', False)))
Ejemplo n.º 3
0
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
Ejemplo n.º 4
0
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
Ejemplo n.º 5
0
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
Ejemplo n.º 6
0
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
Ejemplo n.º 7
0
def build_status(key, *args, **kwargs):
    return mark_safe(BuildStatus.render(key))
Ejemplo n.º 8
0
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