Exemple #1
0
    def validate(self, build, form, **kwargs):
        """
        Custom validation steps for the BuildOutputComplete" form
        """

        data = form.cleaned_data

        output = data.get('output', None)

        stock_status = data.get('stock_status', StockStatus.OK)

        # Any "invalid" stock status defaults to OK
        try:
            stock_status = int(stock_status)
        except (ValueError):
            stock_status = StockStatus.OK

        if int(stock_status) not in StockStatus.keys():
            form.add_error('stock_status', _('Invalid stock status value selected'))

        if output:

            quantity = data.get('quantity', None)

            if quantity and quantity > output.quantity:
                form.add_error('quantity', _('Quantity to complete cannot exceed build output quantity'))

            if not build.isFullyAllocated(output):
                confirm = str2bool(data.get('confirm_incomplete', False))

                if not confirm:
                    form.add_error('confirm_incomplete', _('Confirm completion of incomplete build'))

        else:
            form.add_error(None, _('Build output must be specified'))
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 ''
Exemple #3
0
    def list(self, request, *args, **kwargs):

        queryset = self.filter_queryset(self.get_queryset())

        # Instead of using the DRF serializer to LIST,
        # we will serialize the objects manually.
        # This is significantly faster

        data = queryset.values(
            'pk',
            'quantity',
            'serial',
            'batch',
            'status',
            'notes',
            'location',
            'location__name',
            'location__description',
            'part',
            'part__IPN',
            'part__name',
            'part__revision',
            'part__description',
            'part__image',
            'part__category',
            'part__category__name',
            'part__category__description',
        )

        # Reduce the number of lookups we need to do for categories
        # Cache location lookups for this query
        locations = {}

        for item in data:
            item['part__image'] = os.path.join(settings.MEDIA_URL,
                                               item['part__image'])

            loc_id = item['location']

            if loc_id:
                if loc_id not in locations:
                    locations[loc_id] = StockLocation.objects.get(
                        pk=loc_id).pathstring

                item['location__path'] = locations[loc_id]
            else:
                item['location__path'] = None

            item['status_text'] = StockStatus.label(item['status'])

        return Response(data)
Exemple #4
0
class CompleteBuildOutputForm(HelperForm):
    """
    Form for completing a single build output
    """

    field_prefix = {
        'serial_numbers': 'fa-hashtag',
    }

    field_placeholder = {}

    location = forms.ModelChoiceField(
        queryset=StockLocation.objects.all(),
        label=_('Location'),
        help_text=_('Location of completed parts'),
    )

    stock_status = forms.ChoiceField(
        label=_('Status'),
        help_text=_('Build output stock status'),
        initial=StockStatus.OK,
        choices=StockStatus.items(),
    )

    confirm_incomplete = forms.BooleanField(
        required=False,
        label=_('Confirm incomplete'),
        help_text=_("Confirm completion with incomplete stock allocation"))

    confirm = forms.BooleanField(required=True,
                                 label=_('Confirm'),
                                 help_text=_('Confirm build completion'))

    output = forms.ModelChoiceField(
        queryset=StockItem.objects.all(),  # Queryset is narrowed in the view
        widget=forms.HiddenInput(),
    )

    class Meta:
        model = Build
        fields = [
            'location',
            'output',
            'stock_status',
            'confirm',
            'confirm_incomplete',
        ]

    def __init__(self, *args, **kwargs):

        super().__init__(*args, **kwargs)
Exemple #5
0
class BuildCompleteSerializer(serializers.Serializer):
    """
    DRF serializer for completing one or more build outputs
    """
    class Meta:
        fields = [
            'outputs',
            'location',
            'status',
            'notes',
        ]

    outputs = BuildOutputSerializer(
        many=True,
        required=True,
    )

    location = serializers.PrimaryKeyRelatedField(
        queryset=StockLocation.objects.all(),
        required=True,
        many=False,
        label=_("Location"),
        help_text=_("Location for completed build outputs"),
    )

    status = serializers.ChoiceField(
        choices=list(StockStatus.items()),
        default=StockStatus.OK,
        label=_("Status"),
    )

    notes = serializers.CharField(
        label=_("Notes"),
        required=False,
        allow_blank=True,
    )

    def validate(self, data):

        super().validate(data)

        outputs = data.get('outputs', [])

        if len(outputs) == 0:
            raise ValidationError(
                _("A list of build outputs must be provided"))

        return data

    def save(self):
        """
        "save" the serializer to complete the build outputs
        """

        build = self.context['build']
        request = self.context['request']

        data = self.validated_data

        outputs = data.get('outputs', [])

        # Mark the specified build outputs as "complete"
        with transaction.atomic():
            for item in outputs:

                output = item['output']

                build.complete_build_output(output,
                                            request.user,
                                            status=data['status'],
                                            notes=data.get('notes', ''))
Exemple #6
0
def stock_status_text(key, *args, **kwargs):
    return mark_safe(StockStatus.text(key))
Exemple #7
0
def stock_status_label(key, *args, **kwargs):
    """ Render a StockItem status label """
    return mark_safe(StockStatus.render(key, large=kwargs.get('large', False)))
Exemple #8
0
class StockItem(models.Model):
    """
    A StockItem object represents a quantity of physical instances of a part.
    
    Attributes:
        part: Link to the master abstract part that this StockItem is an instance of
        supplier_part: Link to a specific SupplierPart (optional)
        location: Where this StockItem is located
        quantity: Number of stocked units
        batch: Batch number for this StockItem
        serial: Unique serial number for this StockItem
        URL: Optional URL to link to external resource
        updated: Date that this stock item was last updated (auto)
        stocktake_date: Date of last stocktake for this item
        stocktake_user: User that performed the most recent stocktake
        review_needed: Flag if StockItem needs review
        delete_on_deplete: If True, StockItem will be deleted when the stock level gets to zero
        status: Status of this StockItem (ref: InvenTree.status_codes.StockStatus)
        notes: Extra notes field
        purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder)
        infinite: If True this StockItem can never be exhausted
    """
    def save(self, *args, **kwargs):
        if not self.pk:
            add_note = True
        else:
            add_note = False

        super(StockItem, self).save(*args, **kwargs)

        if add_note:
            # This StockItem is being saved for the first time
            self.addTransactionNote(
                'Created stock item',
                None,
                notes="Created new stock item for part '{p}'".format(
                    p=str(self.part)),
                system=True)

    @classmethod
    def check_serial_number(cls, part, serial_number):
        """ Check if a new stock item can be created with the provided part_id

        Args:
            part: The part to be checked
        """

        if not part.trackable:
            return False

        items = StockItem.objects.filter(serial=serial_number)

        # Is this part a variant? If so, check S/N across all sibling variants
        if part.variant_of is not None:
            items = items.filter(part__variant_of=part.variant_of)
        else:
            items = items.filter(part=part)

        # An existing serial number exists
        if items.exists():
            return False

        return True

    def validate_unique(self, exclude=None):
        super(StockItem, self).validate_unique(exclude)

        # If the Part object is a variant (of a template part),
        # ensure that the serial number is unique
        # across all variants of the same template part

        try:
            if self.serial is not None:
                # This is a variant part (check S/N across all sibling variants)
                if self.part.variant_of is not None:
                    if StockItem.objects.filter(
                            part__variant_of=self.part.variant_of,
                            serial=self.serial).exclude(id=self.id).exists():
                        raise ValidationError({
                            'serial':
                            _('A part with this serial number already exists for template part {part}'
                              .format(part=self.part.variant_of))
                        })
                else:
                    if StockItem.objects.filter(serial=self.serial).exclude(
                            id=self.id).exists():
                        raise ValidationError({
                            'serial':
                            _('A part with this serial number already exists')
                        })
        except Part.DoesNotExist:
            pass

    def clean(self):
        """ Validate the StockItem object (separate to field validation)

        The following validation checks are performed:

        - The 'part' and 'supplier_part.part' fields cannot point to the same Part object
        - The 'part' does not belong to itself
        - Quantity must be 1 if the StockItem has a serial number
        """

        # The 'supplier_part' field must point to the same part!
        try:
            if self.supplier_part is not None:
                if not self.supplier_part.part == self.part:
                    raise ValidationError({
                        'supplier_part':
                        _("Part type ('{pf}') must be {pe}").format(
                            pf=str(self.supplier_part.part), pe=str(self.part))
                    })

            if self.part is not None:
                # A trackable part must have a serial number
                if self.part.trackable:
                    if not self.serial:
                        raise ValidationError({
                            'serial':
                            _('Serial number must be set for trackable items')
                        })

                    if self.delete_on_deplete:
                        raise ValidationError({
                            'delete_on_deplete':
                            _("Must be set to False for trackable items")
                        })

                    # Serial number cannot be set for items with quantity greater than 1
                    if not self.quantity == 1:
                        raise ValidationError({
                            'quantity':
                            _("Quantity must be set to 1 for item with a serial number"
                              ),
                            'serial':
                            _("Serial number cannot be set if quantity > 1")
                        })

                # A template part cannot be instantiated as a StockItem
                if self.part.is_template:
                    raise ValidationError({
                        'part':
                        _('Stock item cannot be created for a template Part')
                    })

        except Part.DoesNotExist:
            # This gets thrown if self.supplier_part is null
            # TODO - Find a test than can be perfomed...
            pass

        if self.belongs_to and self.belongs_to.pk == self.pk:
            raise ValidationError(
                {'belongs_to': _('Item cannot belong to itself')})

    def get_absolute_url(self):
        return reverse('stock-item-detail', kwargs={'pk': self.id})

    def get_part_name(self):
        return self.part.full_name

    class Meta:
        unique_together = [
            ('part', 'serial'),
        ]

    def format_barcode(self):
        """ Return a JSON string for formatting a barcode for this StockItem.
        Can be used to perform lookup of a stockitem using barcode

        Contains the following data:

        { type: 'StockItem', stock_id: <pk>, part_id: <part_pk> }

        Voltagile data (e.g. stock quantity) should be looked up using the InvenTree API (as it may change)
        """

        return helpers.MakeBarcode(
            'StockItem', self.id,
            reverse('api-stock-detail', kwargs={'pk': self.id}), {
                'part_id': self.part.id,
                'part_name': self.part.full_name
            })

    part = models.ForeignKey('part.Part',
                             on_delete=models.CASCADE,
                             related_name='stock_items',
                             help_text='Base part',
                             limit_choices_to={
                                 'is_template': False,
                                 'active': True,
                             })

    supplier_part = models.ForeignKey(
        'company.SupplierPart',
        blank=True,
        null=True,
        on_delete=models.SET_NULL,
        help_text='Select a matching supplier part for this stock item')

    location = models.ForeignKey(StockLocation,
                                 on_delete=models.DO_NOTHING,
                                 related_name='stock_items',
                                 blank=True,
                                 null=True,
                                 help_text='Where is this stock item located?')

    belongs_to = models.ForeignKey(
        'self',
        on_delete=models.DO_NOTHING,
        related_name='owned_parts',
        blank=True,
        null=True,
        help_text='Is this item installed in another item?')

    customer = models.ForeignKey('company.Company',
                                 on_delete=models.SET_NULL,
                                 related_name='stockitems',
                                 blank=True,
                                 null=True,
                                 help_text='Item assigned to customer?')

    serial = models.PositiveIntegerField(
        blank=True, null=True, help_text='Serial number for this item')

    URL = models.URLField(max_length=125, blank=True)

    batch = models.CharField(max_length=100,
                             blank=True,
                             null=True,
                             help_text='Batch code for this stock item')

    quantity = models.PositiveIntegerField(validators=[MinValueValidator(0)],
                                           default=1)

    updated = models.DateField(auto_now=True, null=True)

    purchase_order = models.ForeignKey(
        'order.PurchaseOrder',
        on_delete=models.SET_NULL,
        related_name='stock_items',
        blank=True,
        null=True,
        help_text='Purchase order for this stock item')

    # last time the stock was checked / counted
    stocktake_date = models.DateField(blank=True, null=True)

    stocktake_user = models.ForeignKey(User,
                                       on_delete=models.SET_NULL,
                                       blank=True,
                                       null=True,
                                       related_name='stocktake_stock')

    review_needed = models.BooleanField(default=False)

    delete_on_deplete = models.BooleanField(
        default=True,
        help_text='Delete this Stock Item when stock is depleted')

    status = models.PositiveIntegerField(default=StockStatus.OK,
                                         choices=StockStatus.items(),
                                         validators=[MinValueValidator(0)])

    notes = models.CharField(max_length=250,
                             blank=True,
                             help_text='Stock Item Notes')

    # If stock item is incoming, an (optional) ETA field
    # expected_arrival = models.DateField(null=True, blank=True)

    infinite = models.BooleanField(default=False)

    def can_delete(self):
        # TODO - Return FALSE if this item cannot be deleted!
        return True

    @property
    def in_stock(self):

        if self.belongs_to or self.customer:
            return False

        return True

    @property
    def has_tracking_info(self):
        return self.tracking_info.count() > 0

    def addTransactionNote(self, title, user, notes='', url='', system=True):
        """ Generation a stock transaction note for this item.

        Brief automated note detailing a movement or quantity change.
        """
        track = StockItemTracking.objects.create(item=self,
                                                 title=title,
                                                 user=user,
                                                 quantity=self.quantity,
                                                 date=datetime.now().date(),
                                                 notes=notes,
                                                 URL=url,
                                                 system=system)

        track.save()

    @transaction.atomic
    def splitStock(self, quantity, user):
        """ Split this stock item into two items, in the same location.
        Stock tracking notes for this StockItem will be duplicated,
        and added to the new StockItem.

        Args:
            quantity: Number of stock items to remove from this entity, and pass to the next

        Notes:
            The provided quantity will be subtracted from this item and given to the new one.
            The new item will have a different StockItem ID, while this will remain the same.
        """

        # Doesn't make sense for a zero quantity
        if quantity <= 0:
            return

        # Also doesn't make sense to split the full amount
        if quantity >= self.quantity:
            return

        # Create a new StockItem object, duplicating relevant fields
        new_stock = StockItem.objects.create(
            part=self.part,
            quantity=quantity,
            supplier_part=self.supplier_part,
            location=self.location,
            batch=self.batch,
            delete_on_deplete=self.delete_on_deplete)

        new_stock.save()

        # Add a new tracking item for the new stock item
        new_stock.addTransactionNote(
            "Split from existing stock", user,
            "Split {n} from existing stock item".format(n=quantity))

        # Remove the specified quantity from THIS stock item
        self.take_stock(
            quantity, user,
            'Split {n} items into new stock item'.format(n=quantity))

    @transaction.atomic
    def move(self, location, notes, user, **kwargs):
        """ Move part to a new location.

        Args:
            location: Destination location (cannot be null)
            notes: User notes
            user: Who is performing the move
            kwargs:
                quantity: If provided, override the quantity (default = total stock quantity)
        """

        quantity = int(kwargs.get('quantity', self.quantity))

        if quantity <= 0:
            return False

        if location is None:
            # TODO - Raise appropriate error (cannot move to blank location)
            return False
        elif self.location and (location.pk == self.location.pk):
            # TODO - Raise appropriate error (cannot move to same location)
            return False

        # Test for a partial movement
        if quantity < self.quantity:
            # We need to split the stock!

            # Leave behind certain quantity
            self.splitStock(self.quantity - quantity, user)

        msg = "Moved to {loc}".format(loc=str(location))

        if self.location:
            msg += " (from {loc})".format(loc=str(self.location))

        self.location = location

        self.addTransactionNote(msg, user, notes=notes, system=True)

        self.save()

        return True

    @transaction.atomic
    def updateQuantity(self, quantity):
        """ Update stock quantity for this item.
        
        If the quantity has reached zero, this StockItem will be deleted.

        Returns:
            - True if the quantity was saved
            - False if the StockItem was deleted
        """

        if quantity < 0:
            quantity = 0

        self.quantity = quantity

        if quantity <= 0 and self.delete_on_deplete:
            self.delete()
            return False
        else:
            self.save()
            return True

    @transaction.atomic
    def stocktake(self, count, user, notes=''):
        """ Perform item stocktake.
        When the quantity of an item is counted,
        record the date of stocktake
        """

        count = int(count)

        if count < 0 or self.infinite:
            return False

        self.stocktake_date = datetime.now().date()
        self.stocktake_user = user

        if self.updateQuantity(count):

            self.addTransactionNote(
                'Stocktake - counted {n} items'.format(n=count),
                user,
                notes=notes,
                system=True)

        return True

    @transaction.atomic
    def add_stock(self, quantity, user, notes=''):
        """ Add items to stock
        This function can be called by initiating a ProjectRun,
        or by manually adding the items to the stock location
        """

        quantity = int(quantity)

        # Ignore amounts that do not make sense
        if quantity <= 0 or self.infinite:
            return False

        if self.updateQuantity(self.quantity + quantity):

            self.addTransactionNote(
                'Added {n} items to stock'.format(n=quantity),
                user,
                notes=notes,
                system=True)

        return True

    @transaction.atomic
    def take_stock(self, quantity, user, notes=''):
        """ Remove items from stock
        """

        quantity = int(quantity)

        if quantity <= 0 or self.infinite:
            return False

        if self.updateQuantity(self.quantity - quantity):

            self.addTransactionNote(
                'Removed {n} items from stock'.format(n=quantity),
                user,
                notes=notes,
                system=True)

        return True

    def __str__(self):
        if self.part.trackable and self.serial:
            s = '{part} #{sn}'.format(part=self.part.full_name, sn=self.serial)
        else:
            s = '{n} x {part}'.format(n=self.quantity,
                                      part=self.part.full_name)

        if self.location:
            s += ' @ {loc}'.format(loc=self.location.name)

        return s
Exemple #9
0
class POLineItemReceiveSerializer(serializers.Serializer):
    """
    A serializer for receiving a single purchase order line item against a purchase order
    """

    line_item = serializers.PrimaryKeyRelatedField(
        queryset=order.models.PurchaseOrderLineItem.objects.all(),
        many=False,
        allow_null=False,
        required=True,
        label=_('Line Item'),
    )

    def validate_line_item(self, item):

        if item.order != self.context['order']:
            raise ValidationError(_('Line item does not match purchase order'))

        return item

    location = serializers.PrimaryKeyRelatedField(
        queryset=stock.models.StockLocation.objects.all(),
        many=False,
        allow_null=True,
        required=False,
        label=_('Location'),
        help_text=_('Select destination location for received items'),
    )

    quantity = serializers.DecimalField(
        max_digits=15,
        decimal_places=5,
        min_value=0,
        required=True,
    )

    def validate_quantity(self, quantity):

        if quantity <= 0:
            raise ValidationError(_("Quantity must be greater than zero"))

        return quantity

    status = serializers.ChoiceField(
        choices=list(StockStatus.items()),
        default=StockStatus.OK,
        label=_('Status'),
    )

    barcode = serializers.CharField(
        label=_('Barcode Hash'),
        help_text=_('Unique identifier field'),
        default='',
        required=False,
        allow_null=True,
        allow_blank=True,
    )

    def validate_barcode(self, barcode):
        """
        Cannot check in a LineItem with a barcode that is already assigned
        """

        # Ignore empty barcode values
        if not barcode or barcode.strip() == '':
            return None

        if stock.models.StockItem.objects.filter(uid=barcode).exists():
            raise ValidationError(_('Barcode is already in use'))

        return barcode

    class Meta:
        fields = [
            'barcode',
            'line_item',
            'location',
            'quantity',
            'status',
        ]
Exemple #10
0
    def status_label(self):

        return StockStatus.label(self.status)
Exemple #11
0
class StockItem(MPTTModel):
    """
    A StockItem object represents a quantity of physical instances of a part.
    
    Attributes:
        parent: Link to another StockItem from which this StockItem was created
        part: Link to the master abstract part that this StockItem is an instance of
        supplier_part: Link to a specific SupplierPart (optional)
        location: Where this StockItem is located
        quantity: Number of stocked units
        batch: Batch number for this StockItem
        serial: Unique serial number for this StockItem
        URL: Optional URL to link to external resource
        updated: Date that this stock item was last updated (auto)
        stocktake_date: Date of last stocktake for this item
        stocktake_user: User that performed the most recent stocktake
        review_needed: Flag if StockItem needs review
        delete_on_deplete: If True, StockItem will be deleted when the stock level gets to zero
        status: Status of this StockItem (ref: InvenTree.status_codes.StockStatus)
        notes: Extra notes field
        build: Link to a Build (if this stock item was created from a build)
        purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder)
        infinite: If True this StockItem can never be exhausted
    """

    def save(self, *args, **kwargs):
        if not self.pk:
            add_note = True
        else:
            add_note = False

        user = kwargs.pop('user', None)
        
        add_note = add_note and kwargs.pop('note', True)

        super(StockItem, self).save(*args, **kwargs)

        if add_note:
            # This StockItem is being saved for the first time
            self.addTransactionNote(
                'Created stock item',
                user,
                notes="Created new stock item for part '{p}'".format(p=str(self.part)),
                system=True
            )

    @property
    def status_label(self):

        return StockStatus.label(self.status)

    @property
    def serialized(self):
        """ Return True if this StockItem is serialized """
        return self.serial is not None and self.quantity == 1

    @classmethod
    def check_serial_number(cls, part, serial_number):
        """ Check if a new stock item can be created with the provided part_id

        Args:
            part: The part to be checked
        """

        if not part.trackable:
            return False

        # Return False if an invalid serial number is supplied
        try:
            serial_number = int(serial_number)
        except ValueError:
            return False

        items = StockItem.objects.filter(serial=serial_number)

        # Is this part a variant? If so, check S/N across all sibling variants
        if part.variant_of is not None:
            items = items.filter(part__variant_of=part.variant_of)
        else:
            items = items.filter(part=part)

        # An existing serial number exists
        if items.exists():
            return False

        return True

    def validate_unique(self, exclude=None):
        super(StockItem, self).validate_unique(exclude)

        # If the Part object is a variant (of a template part),
        # ensure that the serial number is unique
        # across all variants of the same template part

        try:
            if self.serial is not None:
                # This is a variant part (check S/N across all sibling variants)
                if self.part.variant_of is not None:
                    if StockItem.objects.filter(part__variant_of=self.part.variant_of, serial=self.serial).exclude(id=self.id).exists():
                        raise ValidationError({
                            'serial': _('A stock item with this serial number already exists for template part {part}'.format(part=self.part.variant_of))
                        })
                else:
                    if StockItem.objects.filter(part=self.part, serial=self.serial).exclude(id=self.id).exists():
                        raise ValidationError({
                            'serial': _('A stock item with this serial number already exists')
                        })
        except Part.DoesNotExist:
            pass

    def clean(self):
        """ Validate the StockItem object (separate to field validation)

        The following validation checks are performed:

        - The 'part' and 'supplier_part.part' fields cannot point to the same Part object
        - The 'part' does not belong to itself
        - Quantity must be 1 if the StockItem has a serial number
        """

        # The 'supplier_part' field must point to the same part!
        try:
            if self.supplier_part is not None:
                if not self.supplier_part.part == self.part:
                    raise ValidationError({'supplier_part': _("Part type ('{pf}') must be {pe}").format(
                                           pf=str(self.supplier_part.part),
                                           pe=str(self.part))
                                           })

            if self.part is not None:
                # A part with a serial number MUST have the quantity set to 1
                if self.serial is not None:
                    if self.quantity > 1:
                        raise ValidationError({
                            'quantity': _('Quantity must be 1 for item with a serial number'),
                            'serial': _('Serial number cannot be set if quantity greater than 1')
                        })

                    if self.quantity == 0:
                        self.quantity = 1

                    elif self.quantity > 1:
                        raise ValidationError({
                            'quantity': _('Quantity must be 1 for item with a serial number')
                        })

                    # Serial numbered items cannot be deleted on depletion
                    self.delete_on_deplete = False

                # A template part cannot be instantiated as a StockItem
                if self.part.is_template:
                    raise ValidationError({'part': _('Stock item cannot be created for a template Part')})

        except Part.DoesNotExist:
            # This gets thrown if self.supplier_part is null
            # TODO - Find a test than can be perfomed...
            pass

        if self.belongs_to and self.belongs_to.pk == self.pk:
            raise ValidationError({
                'belongs_to': _('Item cannot belong to itself')
            })

    def get_absolute_url(self):
        return reverse('stock-item-detail', kwargs={'pk': self.id})

    def get_part_name(self):
        return self.part.full_name

    class Meta:
        unique_together = [
            ('part', 'serial'),
        ]

    def format_barcode(self):
        """ Return a JSON string for formatting a barcode for this StockItem.
        Can be used to perform lookup of a stockitem using barcode

        Contains the following data:

        { type: 'StockItem', stock_id: <pk>, part_id: <part_pk> }

        Voltagile data (e.g. stock quantity) should be looked up using the InvenTree API (as it may change)
        """

        return helpers.MakeBarcode(
            'StockItem',
            self.id,
            reverse('api-stock-detail', kwargs={'pk': self.id}),
            {
                'part_id': self.part.id,
                'part_name': self.part.full_name
            }
        )

    parent = TreeForeignKey('self',
                            on_delete=models.DO_NOTHING,
                            blank=True, null=True,
                            related_name='children')

    part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
                             related_name='stock_items', help_text=_('Base part'),
                             limit_choices_to={
                                 'is_template': False,
                                 'active': True,
                             })

    supplier_part = models.ForeignKey('company.SupplierPart', blank=True, null=True, on_delete=models.SET_NULL,
                                      help_text=_('Select a matching supplier part for this stock item'))

    location = TreeForeignKey(StockLocation, on_delete=models.DO_NOTHING,
                              related_name='stock_items', blank=True, null=True,
                              help_text=_('Where is this stock item located?'))

    belongs_to = models.ForeignKey('self', on_delete=models.DO_NOTHING,
                                   related_name='owned_parts', blank=True, null=True,
                                   help_text=_('Is this item installed in another item?'))

    customer = models.ForeignKey('company.Company', on_delete=models.SET_NULL,
                                 related_name='stockitems', blank=True, null=True,
                                 help_text=_('Item assigned to customer?'))

    serial = models.PositiveIntegerField(blank=True, null=True,
                                         help_text=_('Serial number for this item'))
 
    URL = InvenTreeURLField(max_length=125, blank=True)

    batch = models.CharField(max_length=100, blank=True, null=True,
                             help_text=_('Batch code for this stock item'))

    quantity = models.DecimalField(max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], default=1)

    updated = models.DateField(auto_now=True, null=True)

    build = models.ForeignKey(
        'build.Build', on_delete=models.SET_NULL,
        blank=True, null=True,
        help_text=_('Build for this stock item'),
        related_name='build_outputs',
    )

    purchase_order = models.ForeignKey(
        'order.PurchaseOrder',
        on_delete=models.SET_NULL,
        related_name='stock_items',
        blank=True, null=True,
        help_text=_('Purchase order for this stock item')
    )

    # last time the stock was checked / counted
    stocktake_date = models.DateField(blank=True, null=True)

    stocktake_user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True,
                                       related_name='stocktake_stock')

    review_needed = models.BooleanField(default=False)

    delete_on_deplete = models.BooleanField(default=True, help_text=_('Delete this Stock Item when stock is depleted'))

    status = models.PositiveIntegerField(
        default=StockStatus.OK,
        choices=StockStatus.items(),
        validators=[MinValueValidator(0)])

    notes = MarkdownxField(blank=True, null=True, help_text=_('Stock Item Notes'))

    # If stock item is incoming, an (optional) ETA field
    # expected_arrival = models.DateField(null=True, blank=True)

    infinite = models.BooleanField(default=False)

    def can_delete(self):
        """ Can this stock item be deleted? It can NOT be deleted under the following circumstances:

        - Has child StockItems
        - Has a serial number and is tracked
        - Is installed inside another StockItem
        """

        if self.child_count > 0:
            return False

        if self.part.trackable and self.serial is not None:
            return False

        return True

    @property
    def children(self):
        """ Return a list of the child items which have been split from this stock item """
        return self.get_descendants(include_self=False)

    @property
    def child_count(self):
        """ Return the number of 'child' items associated with this StockItem.
        A child item is one which has been split from this one.
        """
        return self.children.count()

    @property
    def in_stock(self):

        if self.belongs_to or self.customer:
            return False

        return True

    @property
    def has_tracking_info(self):
        return self.tracking_info.count() > 0

    def addTransactionNote(self, title, user, notes='', url='', system=True):
        """ Generation a stock transaction note for this item.

        Brief automated note detailing a movement or quantity change.
        """
        
        track = StockItemTracking.objects.create(
            item=self,
            title=title,
            user=user,
            quantity=self.quantity,
            date=datetime.now().date(),
            notes=notes,
            URL=url,
            system=system
        )

        track.save()

    @transaction.atomic
    def serializeStock(self, quantity, serials, user, notes='', location=None):
        """ Split this stock item into unique serial numbers.

        - Quantity can be less than or equal to the quantity of the stock item
        - Number of serial numbers must match the quantity
        - Provided serial numbers must not already be in use

        Args:
            quantity: Number of items to serialize (integer)
            serials: List of serial numbers (list<int>)
            user: User object associated with action
            notes: Optional notes for tracking
            location: If specified, serialized items will be placed in the given location
        """

        # Cannot serialize stock that is already serialized!
        if self.serialized:
            return

        # Quantity must be a valid integer value
        try:
            quantity = int(quantity)
        except ValueError:
            raise ValidationError({"quantity": _("Quantity must be integer")})

        if quantity <= 0:
            raise ValidationError({"quantity": _("Quantity must be greater than zero")})

        if quantity > self.quantity:
            raise ValidationError({"quantity": _("Quantity must not exceed available stock quantity ({n})".format(n=self.quantity))})

        if not type(serials) in [list, tuple]:
            raise ValidationError({"serial_numbers": _("Serial numbers must be a list of integers")})

        if any([type(i) is not int for i in serials]):
            raise ValidationError({"serial_numbers": _("Serial numbers must be a list of integers")})

        if not quantity == len(serials):
            raise ValidationError({"quantity": _("Quantity does not match serial numbers")})

        # Test if each of the serial numbers are valid
        existing = []

        for serial in serials:
            if not StockItem.check_serial_number(self.part, serial):
                existing.append(serial)

        if len(existing) > 0:
            raise ValidationError({"serial_numbers": _("Serial numbers already exist: ") + str(existing)})

        # Create a new stock item for each unique serial number
        for serial in serials:
            
            # Create a copy of this StockItem
            new_item = StockItem.objects.get(pk=self.pk)
            new_item.quantity = 1
            new_item.serial = serial
            new_item.pk = None
            new_item.parent = self

            if location:
                new_item.location = location

            # The item already has a transaction history, don't create a new note
            new_item.save(user=user, note=False)

            # Copy entire transaction history
            new_item.copyHistoryFrom(self)

            # Create a new stock tracking item
            new_item.addTransactionNote(_('Add serial number'), user, notes=notes)

        # Remove the equivalent number of items
        self.take_stock(quantity, user, notes=_('Serialized {n} items'.format(n=quantity)))

    @transaction.atomic
    def copyHistoryFrom(self, other):
        """ Copy stock history from another part """

        for item in other.tracking_info.all():
            
            item.item = self
            item.pk = None
            item.save()

    @transaction.atomic
    def splitStock(self, quantity, location, user):
        """ Split this stock item into two items, in the same location.
        Stock tracking notes for this StockItem will be duplicated,
        and added to the new StockItem.

        Args:
            quantity: Number of stock items to remove from this entity, and pass to the next
            location: Where to move the new StockItem to

        Notes:
            The provided quantity will be subtracted from this item and given to the new one.
            The new item will have a different StockItem ID, while this will remain the same.
        """

        # Do not split a serialized part
        if self.serialized:
            return

        try:
            quantity = Decimal(quantity)
        except (InvalidOperation, ValueError):
            return

        # Doesn't make sense for a zero quantity
        if quantity <= 0:
            return

        # Also doesn't make sense to split the full amount
        if quantity >= self.quantity:
            return

        # Create a new StockItem object, duplicating relevant fields
        # Nullify the PK so a new record is created
        new_stock = StockItem.objects.get(pk=self.pk)
        new_stock.pk = None
        new_stock.parent = self
        new_stock.quantity = quantity

        # Move to the new location if specified, otherwise use current location
        if location:
            new_stock.location = location
        else:
            new_stock.location = self.location

        new_stock.save()

        # Copy the transaction history of this part into the new one
        new_stock.copyHistoryFrom(self)

        # Add a new tracking item for the new stock item
        new_stock.addTransactionNote(
            "Split from existing stock",
            user,
            "Split {n} from existing stock item".format(n=quantity))

        # Remove the specified quantity from THIS stock item
        self.take_stock(quantity, user, 'Split {n} items into new stock item'.format(n=quantity))

    @transaction.atomic
    def move(self, location, notes, user, **kwargs):
        """ Move part to a new location.

        If less than the available quantity is to be moved,
        a new StockItem is created, with the defined quantity,
        and that new StockItem is moved.
        The quantity is also subtracted from the existing StockItem.

        Args:
            location: Destination location (cannot be null)
            notes: User notes
            user: Who is performing the move
            kwargs:
                quantity: If provided, override the quantity (default = total stock quantity)
        """

        try:
            quantity = Decimal(kwargs.get('quantity', self.quantity))
        except InvalidOperation:
            return False

        if quantity <= 0:
            return False

        if location is None:
            # TODO - Raise appropriate error (cannot move to blank location)
            return False
        elif self.location and (location.pk == self.location.pk) and (quantity == self.quantity):
            # TODO - Raise appropriate error (cannot move to same location)
            return False

        # Test for a partial movement
        if quantity < self.quantity:
            # We need to split the stock!

            # Split the existing StockItem in two
            self.splitStock(quantity, location, user)

            return True

        msg = "Moved to {loc}".format(loc=str(location))

        if self.location:
            msg += " (from {loc})".format(loc=str(self.location))

        self.location = location

        self.addTransactionNote(
            msg,
            user,
            notes=notes,
            system=True)

        self.save()

        return True

    @transaction.atomic
    def updateQuantity(self, quantity):
        """ Update stock quantity for this item.
        
        If the quantity has reached zero, this StockItem will be deleted.

        Returns:
            - True if the quantity was saved
            - False if the StockItem was deleted
        """

        # Do not adjust quantity of a serialized part
        if self.serialized:
            return

        try:
            self.quantity = Decimal(quantity)
        except (InvalidOperation, ValueError):
            return

        if quantity < 0:
            quantity = 0

        self.quantity = quantity

        if quantity == 0 and self.delete_on_deplete and self.can_delete():
            
            # TODO - Do not actually "delete" stock at this point - instead give it a "DELETED" flag
            self.delete()
            return False
        else:
            self.save()
            return True

    @transaction.atomic
    def stocktake(self, count, user, notes=''):
        """ Perform item stocktake.
        When the quantity of an item is counted,
        record the date of stocktake
        """

        try:
            count = Decimal(count)
        except InvalidOperation:
            return False

        if count < 0 or self.infinite:
            return False

        self.stocktake_date = datetime.now().date()
        self.stocktake_user = user

        if self.updateQuantity(count):

            self.addTransactionNote('Stocktake - counted {n} items'.format(n=count),
                                    user,
                                    notes=notes,
                                    system=True)

        return True

    @transaction.atomic
    def add_stock(self, quantity, user, notes=''):
        """ Add items to stock
        This function can be called by initiating a ProjectRun,
        or by manually adding the items to the stock location
        """

        # Cannot add items to a serialized part
        if self.serialized:
            return False

        try:
            quantity = Decimal(quantity)
        except InvalidOperation:
            return False

        # Ignore amounts that do not make sense
        if quantity <= 0 or self.infinite:
            return False

        if self.updateQuantity(self.quantity + quantity):
            
            self.addTransactionNote('Added {n} items to stock'.format(n=quantity),
                                    user,
                                    notes=notes,
                                    system=True)

        return True

    @transaction.atomic
    def take_stock(self, quantity, user, notes=''):
        """ Remove items from stock
        """

        # Cannot remove items from a serialized part
        if self.serialized:
            return False

        try:
            quantity = Decimal(quantity)
        except InvalidOperation:
            return False

        if quantity <= 0 or self.infinite:
            return False

        if self.updateQuantity(self.quantity - quantity):

            self.addTransactionNote('Removed {n} items from stock'.format(n=quantity),
                                    user,
                                    notes=notes,
                                    system=True)

        return True

    def __str__(self):
        if self.part.trackable and self.serial:
            s = '{part} #{sn}'.format(
                part=self.part.full_name,
                sn=self.serial)
        else:
            s = '{n} x {part}'.format(
                n=helpers.decimal2string(self.quantity),
                part=self.part.full_name)

        if self.location:
            s += ' @ {loc}'.format(loc=self.location.name)

        return s
Exemple #12
0
class StockItem(MPTTModel):
    """
    A StockItem object represents a quantity of physical instances of a part.
    
    Attributes:
        parent: Link to another StockItem from which this StockItem was created
        uid: Field containing a unique-id which is mapped to a third-party identifier (e.g. a barcode)
        part: Link to the master abstract part that this StockItem is an instance of
        supplier_part: Link to a specific SupplierPart (optional)
        location: Where this StockItem is located
        quantity: Number of stocked units
        batch: Batch number for this StockItem
        serial: Unique serial number for this StockItem
        link: Optional URL to link to external resource
        updated: Date that this stock item was last updated (auto)
        stocktake_date: Date of last stocktake for this item
        stocktake_user: User that performed the most recent stocktake
        review_needed: Flag if StockItem needs review
        delete_on_deplete: If True, StockItem will be deleted when the stock level gets to zero
        status: Status of this StockItem (ref: InvenTree.status_codes.StockStatus)
        notes: Extra notes field
        build: Link to a Build (if this stock item was created from a build)
        purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder)
        infinite: If True this StockItem can never be exhausted
        sales_order: Link to a SalesOrder object (if the StockItem has been assigned to a SalesOrder)
        build_order: Link to a BuildOrder object (if the StockItem has been assigned to a BuildOrder)
    """

    # A Query filter which will be re-used in multiple places to determine if a StockItem is actually "in stock"
    IN_STOCK_FILTER = Q(sales_order=None,
                        build_order=None,
                        belongs_to=None,
                        customer=None,
                        status__in=StockStatus.AVAILABLE_CODES)

    def save(self, *args, **kwargs):
        """
        Save this StockItem to the database. Performs a number of checks:

        - Unique serial number requirement
        - Adds a transaction note when the item is first created.
        """

        self.validate_unique()
        self.clean()

        if not self.pk:
            # StockItem has not yet been saved
            add_note = True
        else:
            # StockItem has already been saved
            add_note = False

        user = kwargs.pop('user', None)

        add_note = add_note and kwargs.pop('note', True)

        super(StockItem, self).save(*args, **kwargs)

        if add_note:
            # This StockItem is being saved for the first time
            self.addTransactionNote(
                'Created stock item',
                user,
                notes="Created new stock item for part '{p}'".format(
                    p=str(self.part)),
                system=True)

    @property
    def status_label(self):

        return StockStatus.label(self.status)

    @property
    def serialized(self):
        """ Return True if this StockItem is serialized """
        return self.serial is not None and self.quantity == 1

    def validate_unique(self, exclude=None):
        """
        Test that this StockItem is "unique".
        If the StockItem is serialized, the same serial number.
        cannot exist for the same part (or part tree).
        """

        super(StockItem, self).validate_unique(exclude)

        if self.serial is not None:
            # Query to look for duplicate serial numbers
            parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id)
            stock = StockItem.objects.filter(part__in=parts,
                                             serial=self.serial)

            # Exclude myself from the search
            if self.pk is not None:
                stock = stock.exclude(pk=self.pk)

            if stock.exists():
                raise ValidationError({
                    "serial":
                    _("StockItem with this serial number already exists")
                })

    def clean(self):
        """ Validate the StockItem object (separate to field validation)

        The following validation checks are performed:

        - The 'part' and 'supplier_part.part' fields cannot point to the same Part object
        - The 'part' does not belong to itself
        - Quantity must be 1 if the StockItem has a serial number
        """

        super().clean()

        try:
            if self.part.trackable:
                # Trackable parts must have integer values for quantity field!
                if not self.quantity == int(self.quantity):
                    raise ValidationError({
                        'quantity':
                        _('Quantity must be integer value for trackable parts')
                    })
        except PartModels.Part.DoesNotExist:
            # For some reason the 'clean' process sometimes throws errors because self.part does not exist
            # It *seems* that this only occurs in unit testing, though.
            # Probably should investigate this at some point.
            pass

        if self.quantity < 0:
            raise ValidationError(
                {'quantity': _('Quantity must be greater than zero')})

        # The 'supplier_part' field must point to the same part!
        try:
            if self.supplier_part is not None:
                if not self.supplier_part.part == self.part:
                    raise ValidationError({
                        'supplier_part':
                        _("Part type ('{pf}') must be {pe}").format(
                            pf=str(self.supplier_part.part), pe=str(self.part))
                    })

            if self.part is not None:
                # A part with a serial number MUST have the quantity set to 1
                if self.serial is not None:
                    if self.quantity > 1:
                        raise ValidationError({
                            'quantity':
                            _('Quantity must be 1 for item with a serial number'
                              ),
                            'serial':
                            _('Serial number cannot be set if quantity greater than 1'
                              )
                        })

                    if self.quantity == 0:
                        self.quantity = 1

                    elif self.quantity > 1:
                        raise ValidationError({
                            'quantity':
                            _('Quantity must be 1 for item with a serial number'
                              )
                        })

                    # Serial numbered items cannot be deleted on depletion
                    self.delete_on_deplete = False

        except PartModels.Part.DoesNotExist:
            # This gets thrown if self.supplier_part is null
            # TODO - Find a test than can be perfomed...
            pass

        if self.belongs_to and self.belongs_to.pk == self.pk:
            raise ValidationError(
                {'belongs_to': _('Item cannot belong to itself')})

    def get_absolute_url(self):
        return reverse('stock-item-detail', kwargs={'pk': self.id})

    def get_part_name(self):
        return self.part.full_name

    def format_barcode(self, **kwargs):
        """ Return a JSON string for formatting a barcode for this StockItem.
        Can be used to perform lookup of a stockitem using barcode

        Contains the following data:

        { type: 'StockItem', stock_id: <pk>, part_id: <part_pk> }

        Voltagile data (e.g. stock quantity) should be looked up using the InvenTree API (as it may change)
        """

        return helpers.MakeBarcode(
            "stockitem", self.id, {
                "url": reverse('api-stock-detail', kwargs={'pk': self.id}),
            }, **kwargs)

    uid = models.CharField(blank=True,
                           max_length=128,
                           help_text=("Unique identifier field"))

    parent = TreeForeignKey('self',
                            verbose_name=_('Parent Stock Item'),
                            on_delete=models.DO_NOTHING,
                            blank=True,
                            null=True,
                            related_name='children')

    part = models.ForeignKey('part.Part',
                             on_delete=models.CASCADE,
                             verbose_name=_('Base Part'),
                             related_name='stock_items',
                             help_text=_('Base part'),
                             limit_choices_to={
                                 'active': True,
                                 'virtual': False
                             })

    supplier_part = models.ForeignKey(
        'company.SupplierPart',
        blank=True,
        null=True,
        on_delete=models.SET_NULL,
        verbose_name=_('Supplier Part'),
        help_text=_('Select a matching supplier part for this stock item'))

    location = TreeForeignKey(StockLocation,
                              on_delete=models.DO_NOTHING,
                              verbose_name=_('Stock Location'),
                              related_name='stock_items',
                              blank=True,
                              null=True,
                              help_text=_('Where is this stock item located?'))

    belongs_to = models.ForeignKey(
        'self',
        verbose_name=_('Installed In'),
        on_delete=models.DO_NOTHING,
        related_name='owned_parts',
        blank=True,
        null=True,
        help_text=_('Is this item installed in another item?'))

    customer = models.ForeignKey(
        CompanyModels.Company,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        limit_choices_to={'is_customer': True},
        related_name='assigned_stock',
        help_text=_("Customer"),
        verbose_name=_("Customer"),
    )

    serial = models.PositiveIntegerField(
        verbose_name=_('Serial Number'),
        blank=True,
        null=True,
        help_text=_('Serial number for this item'))

    link = InvenTreeURLField(verbose_name=_('External Link'),
                             max_length=125,
                             blank=True,
                             help_text=_("Link to external URL"))

    batch = models.CharField(verbose_name=_('Batch Code'),
                             max_length=100,
                             blank=True,
                             null=True,
                             help_text=_('Batch code for this stock item'))

    quantity = models.DecimalField(verbose_name=_("Stock Quantity"),
                                   max_digits=15,
                                   decimal_places=5,
                                   validators=[MinValueValidator(0)],
                                   default=1)

    updated = models.DateField(auto_now=True, null=True)

    build = models.ForeignKey(
        'build.Build',
        on_delete=models.SET_NULL,
        verbose_name=_('Source Build'),
        blank=True,
        null=True,
        help_text=_('Build for this stock item'),
        related_name='build_outputs',
    )

    purchase_order = models.ForeignKey(
        'order.PurchaseOrder',
        on_delete=models.SET_NULL,
        verbose_name=_('Source Purchase Order'),
        related_name='stock_items',
        blank=True,
        null=True,
        help_text=_('Purchase order for this stock item'))

    sales_order = models.ForeignKey('order.SalesOrder',
                                    on_delete=models.SET_NULL,
                                    verbose_name=_("Destination Sales Order"),
                                    related_name='stock_items',
                                    null=True,
                                    blank=True)

    build_order = models.ForeignKey('build.Build',
                                    on_delete=models.SET_NULL,
                                    verbose_name=_("Destination Build Order"),
                                    related_name='stock_items',
                                    null=True,
                                    blank=True)

    # last time the stock was checked / counted
    stocktake_date = models.DateField(blank=True, null=True)

    stocktake_user = models.ForeignKey(User,
                                       on_delete=models.SET_NULL,
                                       blank=True,
                                       null=True,
                                       related_name='stocktake_stock')

    review_needed = models.BooleanField(default=False)

    delete_on_deplete = models.BooleanField(
        default=True,
        help_text=_('Delete this Stock Item when stock is depleted'))

    status = models.PositiveIntegerField(default=StockStatus.OK,
                                         choices=StockStatus.items(),
                                         validators=[MinValueValidator(0)])

    notes = MarkdownxField(blank=True,
                           null=True,
                           verbose_name=_("Notes"),
                           help_text=_('Stock Item Notes'))

    def clearAllocations(self):
        """
        Clear all order allocations for this StockItem:

        - SalesOrder allocations
        - Build allocations
        """

        # Delete outstanding SalesOrder allocations
        self.sales_order_allocations.all().delete()

        # Delete outstanding BuildOrder allocations
        self.allocations.all().delete()

    def allocateToCustomer(self,
                           customer,
                           quantity=None,
                           order=None,
                           user=None,
                           notes=None):
        """
        Allocate a StockItem to a customer.

        This action can be called by the following processes:
        - Completion of a SalesOrder
        - User manually assigns a StockItem to the customer

        Args:
            customer: The customer (Company) to assign the stock to
            quantity: Quantity to assign (if not supplied, total quantity is used)
            order: SalesOrder reference
            user: User that performed the action
            notes: Notes field
        """

        if quantity is None:
            quantity = self.quantity

        if quantity >= self.quantity:
            item = self
        else:
            item = self.splitStock(quantity, None, user)

        # Update StockItem fields with new information
        item.sales_order = order
        item.customer = customer
        item.location = None

        item.save()

        # TODO - Remove any stock item allocations from this stock item

        item.addTransactionNote(_("Assigned to Customer"),
                                user,
                                notes=_("Manually assigned to customer") +
                                " " + customer.name,
                                system=True)

        # Return the reference to the stock item
        return item

    def returnFromCustomer(self, location, user=None):
        """
        Return stock item from customer, back into the specified location.
        """

        self.addTransactionNote(
            _("Returned from customer") + " " + self.customer.name,
            user,
            notes=_("Returned to location") + " " + location.name,
            system=True)

        self.customer = None
        self.location = location

        self.save()

    # If stock item is incoming, an (optional) ETA field
    # expected_arrival = models.DateField(null=True, blank=True)

    infinite = models.BooleanField(default=False)

    def is_allocated(self):
        """
        Return True if this StockItem is allocated to a SalesOrder or a Build
        """

        # TODO - For now this only checks if the StockItem is allocated to a SalesOrder
        # TODO - In future, once the "build" is working better, check this too

        if self.allocations.count() > 0:
            return True

        if self.sales_order_allocations.count() > 0:
            return True

        return False

    def build_allocation_count(self):
        """
        Return the total quantity allocated to builds
        """

        query = self.allocations.aggregate(
            q=Coalesce(Sum('quantity'), Decimal(0)))

        return query['q']

    def sales_order_allocation_count(self):
        """
        Return the total quantity allocated to SalesOrders
        """

        query = self.sales_order_allocations.aggregate(
            q=Coalesce(Sum('quantity'), Decimal(0)))

        return query['q']

    def allocation_count(self):
        """
        Return the total quantity allocated to builds or orders
        """

        return self.build_allocation_count(
        ) + self.sales_order_allocation_count()

    def unallocated_quantity(self):
        """
        Return the quantity of this StockItem which is *not* allocated
        """

        return max(self.quantity - self.allocation_count(), 0)

    def can_delete(self):
        """ Can this stock item be deleted? It can NOT be deleted under the following circumstances:

        - Has child StockItems
        - Has a serial number and is tracked
        - Is installed inside another StockItem
        - It has been assigned to a SalesOrder
        - It has been assigned to a BuildOrder
        """

        if self.child_count > 0:
            return False

        if self.part.trackable and self.serial is not None:
            return False

        if self.sales_order is not None:
            return False

        if self.build_order is not None:
            return False

        return True

    @property
    def children(self):
        """ Return a list of the child items which have been split from this stock item """
        return self.get_descendants(include_self=False)

    @property
    def child_count(self):
        """ Return the number of 'child' items associated with this StockItem.
        A child item is one which has been split from this one.
        """
        return self.children.count()

    @property
    def in_stock(self):

        # Not 'in stock' if it has been installed inside another StockItem
        if self.belongs_to is not None:
            return False

        # Not 'in stock' if it has been sent to a customer
        if self.sales_order is not None:
            return False

        # Not 'in stock' if it has been allocated to a BuildOrder
        if self.build_order is not None:
            return False

        # Not 'in stock' if it has been assigned to a customer
        if self.customer is not None:
            return False

        # Not 'in stock' if the status code makes it unavailable
        if self.status in StockStatus.UNAVAILABLE_CODES:
            return False

        return True

    @property
    def tracking_info_count(self):
        return self.tracking_info.count()

    @property
    def has_tracking_info(self):
        return self.tracking_info_count > 0

    def addTransactionNote(self, title, user, notes='', url='', system=True):
        """ Generation a stock transaction note for this item.

        Brief automated note detailing a movement or quantity change.
        """

        track = StockItemTracking.objects.create(item=self,
                                                 title=title,
                                                 user=user,
                                                 quantity=self.quantity,
                                                 date=datetime.now().date(),
                                                 notes=notes,
                                                 link=url,
                                                 system=system)

        track.save()

    @transaction.atomic
    def serializeStock(self, quantity, serials, user, notes='', location=None):
        """ Split this stock item into unique serial numbers.

        - Quantity can be less than or equal to the quantity of the stock item
        - Number of serial numbers must match the quantity
        - Provided serial numbers must not already be in use

        Args:
            quantity: Number of items to serialize (integer)
            serials: List of serial numbers (list<int>)
            user: User object associated with action
            notes: Optional notes for tracking
            location: If specified, serialized items will be placed in the given location
        """

        # Cannot serialize stock that is already serialized!
        if self.serialized:
            return

        if not self.part.trackable:
            raise ValidationError({"part": _("Part is not set as trackable")})

        # Quantity must be a valid integer value
        try:
            quantity = int(quantity)
        except ValueError:
            raise ValidationError({"quantity": _("Quantity must be integer")})

        if quantity <= 0:
            raise ValidationError(
                {"quantity": _("Quantity must be greater than zero")})

        if quantity > self.quantity:
            raise ValidationError({
                "quantity":
                _("Quantity must not exceed available stock quantity ({n})".
                  format(n=self.quantity))
            })

        if not type(serials) in [list, tuple]:
            raise ValidationError({
                "serial_numbers":
                _("Serial numbers must be a list of integers")
            })

        if any([type(i) is not int for i in serials]):
            raise ValidationError({
                "serial_numbers":
                _("Serial numbers must be a list of integers")
            })

        if not quantity == len(serials):
            raise ValidationError(
                {"quantity": _("Quantity does not match serial numbers")})

        # Test if each of the serial numbers are valid
        existing = []

        for serial in serials:
            if self.part.checkIfSerialNumberExists(serial):
                existing.append(serial)

        if len(existing) > 0:
            raise ValidationError({
                "serial_numbers":
                _("Serial numbers already exist: ") + str(existing)
            })

        # Create a new stock item for each unique serial number
        for serial in serials:

            # Create a copy of this StockItem
            new_item = StockItem.objects.get(pk=self.pk)
            new_item.quantity = 1
            new_item.serial = serial
            new_item.pk = None
            new_item.parent = self

            if location:
                new_item.location = location

            # The item already has a transaction history, don't create a new note
            new_item.save(user=user, note=False)

            # Copy entire transaction history
            new_item.copyHistoryFrom(self)

            # Copy test result history
            new_item.copyTestResultsFrom(self)

            # Create a new stock tracking item
            new_item.addTransactionNote(_('Add serial number'),
                                        user,
                                        notes=notes)

        # Remove the equivalent number of items
        self.take_stock(quantity,
                        user,
                        notes=_('Serialized {n} items'.format(n=quantity)))

    @transaction.atomic
    def copyHistoryFrom(self, other):
        """ Copy stock history from another StockItem """

        for item in other.tracking_info.all():

            item.item = self
            item.pk = None
            item.save()

    @transaction.atomic
    def copyTestResultsFrom(self, other, filters={}):
        """ Copy all test results from another StockItem """

        for result in other.test_results.all().filter(**filters):

            # Create a copy of the test result by nulling-out the pk
            result.pk = None
            result.stock_item = self
            result.save()

    @transaction.atomic
    def splitStock(self, quantity, location, user):
        """ Split this stock item into two items, in the same location.
        Stock tracking notes for this StockItem will be duplicated,
        and added to the new StockItem.

        Args:
            quantity: Number of stock items to remove from this entity, and pass to the next
            location: Where to move the new StockItem to

        Notes:
            The provided quantity will be subtracted from this item and given to the new one.
            The new item will have a different StockItem ID, while this will remain the same.
        """

        # Do not split a serialized part
        if self.serialized:
            return

        try:
            quantity = Decimal(quantity)
        except (InvalidOperation, ValueError):
            return

        # Doesn't make sense for a zero quantity
        if quantity <= 0:
            return

        # Also doesn't make sense to split the full amount
        if quantity >= self.quantity:
            return

        # Create a new StockItem object, duplicating relevant fields
        # Nullify the PK so a new record is created
        new_stock = StockItem.objects.get(pk=self.pk)
        new_stock.pk = None
        new_stock.parent = self
        new_stock.quantity = quantity

        # Move to the new location if specified, otherwise use current location
        if location:
            new_stock.location = location
        else:
            new_stock.location = self.location

        new_stock.save()

        # Copy the transaction history of this part into the new one
        new_stock.copyHistoryFrom(self)

        # Copy the test results of this part to the new one
        new_stock.copyTestResultsFrom(self)

        # Add a new tracking item for the new stock item
        new_stock.addTransactionNote(
            "Split from existing stock", user,
            "Split {n} from existing stock item".format(n=quantity))

        # Remove the specified quantity from THIS stock item
        self.take_stock(
            quantity, user,
            'Split {n} items into new stock item'.format(n=quantity))

        # Return a copy of the "new" stock item
        return new_stock

    @transaction.atomic
    def move(self, location, notes, user, **kwargs):
        """ Move part to a new location.

        If less than the available quantity is to be moved,
        a new StockItem is created, with the defined quantity,
        and that new StockItem is moved.
        The quantity is also subtracted from the existing StockItem.

        Args:
            location: Destination location (cannot be null)
            notes: User notes
            user: Who is performing the move
            kwargs:
                quantity: If provided, override the quantity (default = total stock quantity)
        """

        try:
            quantity = Decimal(kwargs.get('quantity', self.quantity))
        except InvalidOperation:
            return False

        if not self.in_stock:
            raise ValidationError(
                _("StockItem cannot be moved as it is not in stock"))

        if quantity <= 0:
            return False

        if location is None:
            # TODO - Raise appropriate error (cannot move to blank location)
            return False
        elif self.location and (location.pk
                                == self.location.pk) and (quantity
                                                          == self.quantity):
            # TODO - Raise appropriate error (cannot move to same location)
            return False

        # Test for a partial movement
        if quantity < self.quantity:
            # We need to split the stock!

            # Split the existing StockItem in two
            self.splitStock(quantity, location, user)

            return True

        msg = "Moved to {loc}".format(loc=str(location))

        if self.location:
            msg += " (from {loc})".format(loc=str(self.location))

        self.location = location

        self.addTransactionNote(msg, user, notes=notes, system=True)

        self.save()

        return True

    @transaction.atomic
    def updateQuantity(self, quantity):
        """ Update stock quantity for this item.
        
        If the quantity has reached zero, this StockItem will be deleted.

        Returns:
            - True if the quantity was saved
            - False if the StockItem was deleted
        """

        # Do not adjust quantity of a serialized part
        if self.serialized:
            return

        try:
            self.quantity = Decimal(quantity)
        except (InvalidOperation, ValueError):
            return

        if quantity < 0:
            quantity = 0

        self.quantity = quantity

        if quantity == 0 and self.delete_on_deplete and self.can_delete():

            # TODO - Do not actually "delete" stock at this point - instead give it a "DELETED" flag
            self.delete()
            return False
        else:
            self.save()
            return True

    @transaction.atomic
    def stocktake(self, count, user, notes=''):
        """ Perform item stocktake.
        When the quantity of an item is counted,
        record the date of stocktake
        """

        try:
            count = Decimal(count)
        except InvalidOperation:
            return False

        if count < 0 or self.infinite:
            return False

        self.stocktake_date = datetime.now().date()
        self.stocktake_user = user

        if self.updateQuantity(count):

            self.addTransactionNote(
                'Stocktake - counted {n} items'.format(n=count),
                user,
                notes=notes,
                system=True)

        return True

    @transaction.atomic
    def add_stock(self, quantity, user, notes=''):
        """ Add items to stock
        This function can be called by initiating a ProjectRun,
        or by manually adding the items to the stock location
        """

        # Cannot add items to a serialized part
        if self.serialized:
            return False

        try:
            quantity = Decimal(quantity)
        except InvalidOperation:
            return False

        # Ignore amounts that do not make sense
        if quantity <= 0 or self.infinite:
            return False

        if self.updateQuantity(self.quantity + quantity):

            self.addTransactionNote(
                'Added {n} items to stock'.format(n=quantity),
                user,
                notes=notes,
                system=True)

        return True

    @transaction.atomic
    def take_stock(self, quantity, user, notes=''):
        """ Remove items from stock
        """

        # Cannot remove items from a serialized part
        if self.serialized:
            return False

        try:
            quantity = Decimal(quantity)
        except InvalidOperation:
            return False

        if quantity <= 0 or self.infinite:
            return False

        if self.updateQuantity(self.quantity - quantity):

            self.addTransactionNote(
                'Removed {n} items from stock'.format(n=quantity),
                user,
                notes=notes,
                system=True)

        return True

    def __str__(self):
        if self.part.trackable and self.serial:
            s = '{part} #{sn}'.format(part=self.part.full_name, sn=self.serial)
        else:
            s = '{n} x {part}'.format(n=helpers.decimal2string(self.quantity),
                                      part=self.part.full_name)

        if self.location:
            s += ' @ {loc}'.format(loc=self.location.name)

        return s

    def getTestResults(self, test=None, result=None, user=None):
        """
        Return all test results associated with this StockItem.

        Optionally can filter results by:
        - Test name
        - Test result
        - User
        """

        results = self.test_results

        if test:
            # Filter by test name
            results = results.filter(test=test)

        if result is not None:
            # Filter by test status
            results = results.filter(result=result)

        if user:
            # Filter by user
            results = results.filter(user=user)

        return results

    def testResultMap(self, **kwargs):
        """
        Return a map of test-results using the test name as the key.
        Where multiple test results exist for a given name,
        the *most recent* test is used.

        This map is useful for rendering to a template (e.g. a test report),
        as all named tests are accessible.
        """

        results = self.getTestResults(**kwargs).order_by('-date')

        result_map = {}

        for result in results:
            key = helpers.generateTestKey(result.test)
            result_map[key] = result

        return result_map

    def testResultList(self, **kwargs):
        """
        Return a list of test-result objects for this StockItem
        """

        return self.testResultMap(**kwargs).values()

    def requiredTestStatus(self):
        """
        Return the status of the tests required for this StockItem.

        return:
            A dict containing the following items:
            - total: Number of required tests
            - passed: Number of tests that have passed
            - failed: Number of tests that have failed
        """

        # All the tests required by the part object
        required = self.part.getRequiredTests()

        results = self.testResultMap()

        total = len(required)
        passed = 0
        failed = 0

        for test in required:
            key = helpers.generateTestKey(test.test_name)

            if key in results:
                result = results[key]

                if result.result:
                    passed += 1
                else:
                    failed += 1

        return {
            'total': total,
            'passed': passed,
            'failed': failed,
        }

    @property
    def required_test_count(self):
        return self.part.getRequiredTests().count()

    def hasRequiredTests(self):
        return self.part.getRequiredTests().count() > 0

    def passedAllRequiredTests(self):

        status = self.requiredTestStatus()

        return status['passed'] >= status['total']
def stock_status(key, *args, **kwargs):
    return mark_safe(StockStatus.render(key))
Exemple #14
0
class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
    """
    A serializer for receiving a single purchase order line item against a purchase order
    """
    class Meta:
        fields = [
            'barcode',
            'line_item',
            'location',
            'quantity',
            'status',
            'batch_code'
            'serial_numbers',
        ]

    line_item = serializers.PrimaryKeyRelatedField(
        queryset=order.models.PurchaseOrderLineItem.objects.all(),
        many=False,
        allow_null=False,
        required=True,
        label=_('Line Item'),
    )

    def validate_line_item(self, item):

        if item.order != self.context['order']:
            raise ValidationError(_('Line item does not match purchase order'))

        return item

    location = serializers.PrimaryKeyRelatedField(
        queryset=stock.models.StockLocation.objects.all(),
        many=False,
        allow_null=True,
        required=False,
        label=_('Location'),
        help_text=_('Select destination location for received items'),
    )

    quantity = serializers.DecimalField(
        max_digits=15,
        decimal_places=5,
        min_value=0,
        required=True,
    )

    def validate_quantity(self, quantity):

        if quantity <= 0:
            raise ValidationError(_("Quantity must be greater than zero"))

        return quantity

    batch_code = serializers.CharField(
        label=_('Batch Code'),
        help_text=_('Enter batch code for incoming stock items'),
        required=False,
        default='',
        allow_blank=True,
    )

    serial_numbers = serializers.CharField(
        label=_('Serial Numbers'),
        help_text=_('Enter serial numbers for incoming stock items'),
        required=False,
        default='',
        allow_blank=True,
    )

    status = serializers.ChoiceField(
        choices=list(StockStatus.items()),
        default=StockStatus.OK,
        label=_('Status'),
    )

    barcode = serializers.CharField(
        label=_('Barcode Hash'),
        help_text=_('Unique identifier field'),
        default='',
        required=False,
        allow_null=True,
        allow_blank=True,
    )

    def validate_barcode(self, barcode):
        """
        Cannot check in a LineItem with a barcode that is already assigned
        """

        # Ignore empty barcode values
        if not barcode or barcode.strip() == '':
            return None

        if stock.models.StockItem.objects.filter(uid=barcode).exists():
            raise ValidationError(_('Barcode is already in use'))

        return barcode

    def validate(self, data):

        data = super().validate(data)

        line_item = data['line_item']
        quantity = data['quantity']
        serial_numbers = data.get('serial_numbers', '').strip()

        base_part = line_item.part.part

        # Does the quantity need to be "integer" (for trackable parts?)
        if base_part.trackable:

            if Decimal(quantity) != int(quantity):
                raise ValidationError({
                    'quantity':
                    _('An integer quantity must be provided for trackable parts'
                      ),
                })

        # If serial numbers are provided
        if serial_numbers:
            try:
                # Pass the serial numbers through to the parent serializer once validated
                data['serials'] = extract_serial_numbers(
                    serial_numbers, quantity,
                    base_part.getLatestSerialNumberInt())
            except DjangoValidationError as e:
                raise ValidationError({
                    'serial_numbers': e.messages,
                })

        return data
Exemple #15
0
def stock_status_text(key, *args, **kwargs):
    """Render the text value of a StockItem status value"""
    return mark_safe(StockStatus.text(key))
Exemple #16
0
    def get(self, request, *args, **kwargs):

        export_format = request.GET.get('format', 'csv').lower()

        # Check if a particular location was specified
        loc_id = request.GET.get('location', None)
        location = None

        if loc_id:
            try:
                location = StockLocation.objects.get(pk=loc_id)
            except (ValueError, StockLocation.DoesNotExist):
                pass

        # Check if a particular supplier was specified
        sup_id = request.GET.get('supplier', None)
        supplier = None

        if sup_id:
            try:
                supplier = Company.objects.get(pk=sup_id)
            except (ValueError, Company.DoesNotExist):
                pass

        # Check if a particular part was specified
        part_id = request.GET.get('part', None)
        part = None

        if part_id:
            try:
                part = Part.objects.get(pk=part_id)
            except (ValueError, Part.DoesNotExist):
                pass

        if export_format not in GetExportFormats():
            export_format = 'csv'

        filename = 'InvenTree_Stocktake_{date}.{fmt}'.format(
            date=datetime.now().strftime("%d-%b-%Y"), fmt=export_format)

        if location:
            # CHeck if locations should be cascading
            cascade = str2bool(request.GET.get('cascade', True))
            stock_items = location.get_stock_items(cascade)
        else:
            cascade = True
            stock_items = StockItem.objects.all()

        if part:
            stock_items = stock_items.filter(part=part)

        if supplier:
            stock_items = stock_items.filter(supplier_part__supplier=supplier)

        # Filter out stock items that are not 'in stock'
        stock_items = stock_items.filter(customer=None)
        stock_items = stock_items.filter(belongs_to=None)

        # Pre-fetch related fields to reduce DB queries
        stock_items = stock_items.prefetch_related('part',
                                                   'supplier_part__supplier',
                                                   'location',
                                                   'purchase_order', 'build')

        # Column headers
        headers = [
            _('Stock ID'),
            _('Part ID'),
            _('Part'),
            _('Supplier Part ID'),
            _('Supplier ID'),
            _('Supplier'),
            _('Location ID'),
            _('Location'),
            _('Quantity'),
            _('Batch'),
            _('Serial'),
            _('Status'),
            _('Notes'),
            _('Review Needed'),
            _('Last Updated'),
            _('Last Stocktake'),
            _('Purchase Order ID'),
            _('Build ID'),
        ]

        data = tablib.Dataset(headers=headers)

        for item in stock_items:
            line = []

            line.append(item.pk)
            line.append(item.part.pk)
            line.append(item.part.full_name)

            if item.supplier_part:
                line.append(item.supplier_part.pk)
                line.append(item.supplier_part.supplier.pk)
                line.append(item.supplier_part.supplier.name)
            else:
                line.append('')
                line.append('')
                line.append('')

            if item.location:
                line.append(item.location.pk)
                line.append(item.location.name)
            else:
                line.append('')
                line.append('')

            line.append(item.quantity)
            line.append(item.batch)
            line.append(item.serial)
            line.append(StockStatus.label(item.status))
            line.append(item.notes)
            line.append(item.review_needed)
            line.append(item.updated)
            line.append(item.stocktake_date)

            if item.purchase_order:
                line.append(item.purchase_order.pk)
            else:
                line.append('')

            if item.build:
                line.append(item.build.pk)
            else:
                line.append('')

            data.append(line)

        filedata = data.export(export_format)

        return DownloadFile(filedata, filename)