Example #1
0
    def get_bom_price_range(self, quantity=1):
        """ Return the price range of the BOM for this part.
        Adds the minimum price for all components in the BOM.

        Note: If the BOM contains items without pricing information,
        these items cannot be included in the BOM!
        """

        min_price = None
        max_price = None

        for item in self.bom_items.all().select_related('sub_part'):
            prices = item.sub_part.get_price_range(quantity * item.quantity)

            if prices is None:
                continue

            low, high = prices

            if min_price is None:
                min_price = 0

            if max_price is None:
                max_price = 0

            min_price += low
            max_price += high

        if min_price is None or max_price is None:
            return None

        min_price = normalize(min_price)
        max_price = normalize(max_price)

        return (min_price, max_price)
Example #2
0
    def get_supplier_price_range(self, quantity=1):

        min_price = None
        max_price = None

        for supplier in self.supplier_parts.all():

            price = supplier.get_price(quantity)

            if price is None:
                continue

            if min_price is None or price < min_price:
                min_price = price

            if max_price is None or price > max_price:
                max_price = price

        if min_price is None or max_price is None:
            return None

        min_price = normalize(min_price)
        max_price = normalize(max_price)

        return (min_price, max_price)
Example #3
0
    def clean(self):
        """ Check validity of the BuildItem model.
        The following checks are performed:

        - StockItem.part must be in the BOM of the Part object referenced by Build
        - Allocation quantity cannot exceed available quantity
        """

        self.validate_unique()

        super().clean()

        errors = {}

        try:

            # If the 'part' is trackable, then the 'install_into' field must be set!
            if self.stock_item.part and self.stock_item.part.trackable and not self.install_into:
                raise ValidationError(
                    _('Build item must specify a build output, as master part is marked as trackable'
                      ))

            # Allocated part must be in the BOM for the master part
            if self.stock_item.part not in self.build.part.getRequiredParts(
                    recursive=False):
                errors['stock_item'] = [
                    _("Selected stock item not found in BOM for part '{p}'").
                    format(p=self.build.part.full_name)
                ]

            # Allocated quantity cannot exceed available stock quantity
            if self.quantity > self.stock_item.quantity:
                errors['quantity'] = [
                    _("Allocated quantity ({n}) must not exceed available quantity ({q})"
                      ).format(n=normalize(self.quantity),
                               q=normalize(self.stock_item.quantity))
                ]

            # Allocated quantity cannot cause the stock item to be over-allocated
            if self.stock_item.quantity - self.stock_item.allocation_count(
            ) + self.quantity < self.quantity:
                errors['quantity'] = _('StockItem is over-allocated')

            # Allocated quantity must be positive
            if self.quantity <= 0:
                errors['quantity'] = _(
                    'Allocation quantity must be greater than zero')

            # Quantity must be 1 for serialized stock
            if self.stock_item.serialized and not self.quantity == 1:
                errors['quantity'] = _(
                    'Quantity must be 1 for serialized stock')

        except (StockModels.StockItem.DoesNotExist,
                PartModels.Part.DoesNotExist):
            pass

        if len(errors) > 0:
            raise ValidationError(errors)
Example #4
0
    def validate(self, build_item, form, **kwargs):
        """
        Extra validation steps as required
        """

        data = form.cleaned_data

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

        if stock_item:
            # Stock item must actually be in stock!
            if not stock_item.in_stock:
                form.add_error('stock_item',
                               _('Item must be currently in stock'))

            # Check that there are enough items available
            if quantity is not None:
                available = stock_item.unallocated_quantity()
                if quantity > available:
                    form.add_error('stock_item',
                                   _('Stock item is over-allocated'))
                    form.add_error(
                        'quantity',
                        _('Available') + ': ' + str(normalize(available)))
        else:
            form.add_error('stock_item', _('Stock item must be selected'))
Example #5
0
    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=helpers.normalize(count)),
                                    user,
                                    notes=notes,
                                    system=True)

        return True
Example #6
0
    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=helpers.normalize(quantity)),
                                    user,
                                    notes=notes,
                                    system=True)

        return True
Example #7
0
    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=helpers.normalize(quantity)),
                                    user,
                                    notes=notes,
                                    system=True)

        return True
Example #8
0
    def dehydrate_quantity(self, item):
        """
        Special consideration for the 'quantity' field on data export.
        We do not want a spreadsheet full of "1.0000" (we'd rather "1")

        Ref: https://django-import-export.readthedocs.io/en/latest/getting_started.html#advanced-data-manipulation-on-export
        """
        return normalize(item.quantity)
Example #9
0
    def prepare_value(self, value):
        """
        Override the 'prepare_value' method, to remove trailing zeros when displaying.
        Why? It looks nice!
        """

        if type(value) == Decimal:
            return normalize(value)
        else:
            return value
Example #10
0
    def get_price_info(self, quantity=1, buy=True, bom=True):
        """ Return a simplified pricing string for this part
        
        Args:
            quantity: Number of units to calculate price for
            buy: Include supplier pricing (default = True)
            bom: Include BOM pricing (default = True)
        """

        price_range = self.get_price_range(quantity, buy, bom)

        if price_range is None:
            return None

        min_price, max_price = price_range

        if min_price == max_price:
            return min_price

        min_price = normalize(min_price)
        max_price = normalize(max_price)

        return "{a} - {b}".format(a=min_price, b=max_price)
Example #11
0
    def get_price(self, quantity, moq=True, multiples=True, currency=None):
        """ Calculate the supplier price based on quantity price breaks.

        - Don't forget to add in flat-fee cost (base_cost field)
        - If MOQ (minimum order quantity) is required, bump quantity
        - If order multiples are to be observed, then we need to calculate based on that, too
        """

        price_breaks = self.price_breaks.filter(quantity__lte=quantity)

        # No price break information available?
        if len(price_breaks) == 0:
            return None

        # Order multiples
        if multiples:
            quantity = int(math.ceil(quantity / self.multiple) * self.multiple)

        pb_found = False
        pb_quantity = -1
        pb_cost = 0.0

        if currency is None:
            # Default currency selection
            currency = common.models.InvenTreeSetting.get_setting(
                'INVENTREE_DEFAULT_CURRENCY')

        for pb in self.price_breaks.all():
            # Ignore this pricebreak (quantity is too high)
            if pb.quantity > quantity:
                continue

            pb_found = True

            # If this price-break quantity is the largest so far, use it!
            if pb.quantity > pb_quantity:
                pb_quantity = pb.quantity

                # Convert everything to the selected currency
                pb_cost = pb.convert_to(currency)

        if pb_found:
            cost = pb_cost * quantity
            return normalize(cost + self.base_cost)
        else:
            return None
Example #12
0
    def get_context_data(self, request):
        """
        Generate context data for each provided StockItem
        """

        stock_item = self.object_to_print

        return {
            'item': stock_item,
            'part': stock_item.part,
            'name': stock_item.part.full_name,
            'ipn': stock_item.part.IPN,
            'quantity': normalize(stock_item.quantity),
            'serial': stock_item.serial,
            'uid': stock_item.uid,
            'qr_data': stock_item.format_barcode(brief=True),
            'tests': stock_item.testResultMap()
        }
Example #13
0
    def validate(self, data):

        data = super().validate(data)

        stock_item = data['stock_item']
        quantity = data['quantity']

        if stock_item.serialized and quantity != 1:
            raise ValidationError({
                'quantity': _("Quantity must be 1 for serialized stock item"),
            })

        q = normalize(stock_item.unallocated_quantity())

        if quantity > q:
            raise ValidationError({
                'quantity': _(f"Available quantity ({q}) exceeded")
            })

        return data
Example #14
0
    def get_record_data(self, items):
        """
        Generate context data for each provided StockItem
        """
        records = []

        for item in items:

            # Add some basic information
            records.append({
                'item': item,
                'part': item.part,
                'name': item.part.name,
                'ipn': item.part.IPN,
                'quantity': normalize(item.quantity),
                'serial': item.serial,
                'uid': item.uid,
                'pk': item.pk,
                'qr_data': item.format_barcode(brief=True),
                'tests': item.testResultMap()
            })

        return records
Example #15
0
    def validate(self, data):
        """Custom validation for the serializer:

        - Ensure that the quantity is 1 for serialized stock
        - Quantity cannot exceed the available amount
        """
        data = super().validate(data)

        stock_item = data['stock_item']
        quantity = data['quantity']

        if stock_item.serialized and quantity != 1:
            raise ValidationError({
                'quantity': _("Quantity must be 1 for serialized stock item"),
            })

        q = normalize(stock_item.unallocated_quantity())

        if quantity > q:
            raise ValidationError({
                'quantity': _(f"Available quantity ({q}) exceeded")
            })

        return data
Example #16
0
def ExportBom(part,
              fmt='csv',
              cascade=False,
              max_levels=None,
              parameter_data=False,
              stock_data=False,
              supplier_data=False,
              manufacturer_data=False):
    """ Export a BOM (Bill of Materials) for a given part.

    Args:
        fmt: File format (default = 'csv')
        cascade: If True, multi-level BOM output is supported. Otherwise, a flat top-level-only BOM is exported.
    """

    if not IsValidBOMFormat(fmt):
        fmt = 'csv'

    bom_items = []

    uids = []

    def add_items(items, level, cascade=True):
        # Add items at a given layer
        for item in items:

            item.level = str(int(level))

            # Avoid circular BOM references
            if item.pk in uids:
                continue

            bom_items.append(item)

            if cascade and item.sub_part.assembly:
                if max_levels is None or level < max_levels:
                    add_items(item.sub_part.bom_items.all().order_by('id'),
                              level + 1)

    top_level_items = part.get_bom_items().order_by('id')

    add_items(top_level_items, 1, cascade)

    dataset = BomItemResource().export(queryset=bom_items, cascade=cascade)

    def add_columns_to_dataset(columns, column_size):
        try:
            for header, column_dict in columns.items():
                # Construct column tuple
                col = tuple(
                    column_dict.get(c_idx, '') for c_idx in range(column_size))
                # Add column to dataset
                dataset.append_col(col, header=header)
        except AttributeError:
            pass

    if parameter_data:
        """
        If requested, add extra columns for each PartParameter associated with each line item
        """

        parameter_cols = {}

        for b_idx, bom_item in enumerate(bom_items):
            # Get part parameters
            parameters = bom_item.sub_part.get_parameters()
            # Add parameters to columns
            if parameters:
                for parameter in parameters:
                    name = parameter.template.name
                    value = parameter.data

                    try:
                        parameter_cols[name].update({b_idx: value})
                    except KeyError:
                        parameter_cols[name] = {b_idx: value}

        # Add parameter columns to dataset
        parameter_cols_ordered = OrderedDict(
            sorted(parameter_cols.items(), key=lambda x: x[0]))
        add_columns_to_dataset(parameter_cols_ordered, len(bom_items))

    if stock_data:
        """
        If requested, add extra columns for stock data associated with each line item
        """

        stock_headers = [
            _('Default Location'),
            _('Available Stock'),
        ]

        stock_cols = {}

        for b_idx, bom_item in enumerate(bom_items):
            stock_data = []
            # Get part default location
            try:
                loc = bom_item.sub_part.get_default_location()

                if loc is not None:
                    stock_data.append(str(loc.name))
                else:
                    stock_data.append('')
            except AttributeError:
                stock_data.append('')

            # Get part current stock
            stock_data.append(str(normalize(
                bom_item.sub_part.available_stock)))

            for s_idx, header in enumerate(stock_headers):
                try:
                    stock_cols[header].update({b_idx: stock_data[s_idx]})
                except KeyError:
                    stock_cols[header] = {b_idx: stock_data[s_idx]}

        # Add stock columns to dataset
        add_columns_to_dataset(stock_cols, len(bom_items))

    if manufacturer_data or supplier_data:
        """
        If requested, add extra columns for each SupplierPart and ManufacturerPart associated with each line item
        """

        # Keep track of the supplier parts we have already exported
        supplier_parts_used = set()

        manufacturer_cols = {}

        for bom_idx, bom_item in enumerate(bom_items):
            # Get part instance
            b_part = bom_item.sub_part

            # Include manufacturer data for each BOM item
            if manufacturer_data:

                # Filter manufacturer parts
                manufacturer_parts = ManufacturerPart.objects.filter(
                    part__pk=b_part.pk).prefetch_related('supplier_parts')

                for mp_idx, mp_part in enumerate(manufacturer_parts):

                    # Extract the "name" field of the Manufacturer (Company)
                    if mp_part and mp_part.manufacturer:
                        manufacturer_name = mp_part.manufacturer.name
                    else:
                        manufacturer_name = ''

                    # Extract the "MPN" field from the Manufacturer Part
                    if mp_part:
                        manufacturer_mpn = mp_part.MPN
                    else:
                        manufacturer_mpn = ''

                    # Generate a column name for this manufacturer
                    k_man = f'{_("Manufacturer")}_{mp_idx}'
                    k_mpn = f'{_("MPN")}_{mp_idx}'

                    try:
                        manufacturer_cols[k_man].update(
                            {bom_idx: manufacturer_name})
                        manufacturer_cols[k_mpn].update(
                            {bom_idx: manufacturer_mpn})
                    except KeyError:
                        manufacturer_cols[k_man] = {bom_idx: manufacturer_name}
                        manufacturer_cols[k_mpn] = {bom_idx: manufacturer_mpn}

                    # We wish to include supplier data for this manufacturer part
                    if supplier_data:

                        for sp_idx, sp_part in enumerate(
                                mp_part.supplier_parts.all()):

                            supplier_parts_used.add(sp_part)

                            if sp_part.supplier and sp_part.supplier:
                                supplier_name = sp_part.supplier.name
                            else:
                                supplier_name = ''

                            if sp_part:
                                supplier_sku = sp_part.SKU
                            else:
                                supplier_sku = ''

                            # Generate column names for this supplier
                            k_sup = str(_("Supplier")) + "_" + str(
                                mp_idx) + "_" + str(sp_idx)
                            k_sku = str(_("SKU")) + "_" + str(
                                mp_idx) + "_" + str(sp_idx)

                            try:
                                manufacturer_cols[k_sup].update(
                                    {bom_idx: supplier_name})
                                manufacturer_cols[k_sku].update(
                                    {bom_idx: supplier_sku})
                            except KeyError:
                                manufacturer_cols[k_sup] = {
                                    bom_idx: supplier_name
                                }
                                manufacturer_cols[k_sku] = {
                                    bom_idx: supplier_sku
                                }

            if supplier_data:
                # Add in any extra supplier parts, which are not associated with a manufacturer part

                for sp_idx, sp_part in enumerate(
                        SupplierPart.objects.filter(part__pk=b_part.pk)):

                    if sp_part in supplier_parts_used:
                        continue

                    supplier_parts_used.add(sp_part)

                    if sp_part.supplier:
                        supplier_name = sp_part.supplier.name
                    else:
                        supplier_name = ''

                    supplier_sku = sp_part.SKU

                    # Generate column names for this supplier
                    k_sup = str(_("Supplier")) + "_" + str(sp_idx)
                    k_sku = str(_("SKU")) + "_" + str(sp_idx)

                    try:
                        manufacturer_cols[k_sup].update(
                            {bom_idx: supplier_name})
                        manufacturer_cols[k_sku].update(
                            {bom_idx: supplier_sku})
                    except KeyError:
                        manufacturer_cols[k_sup] = {bom_idx: supplier_name}
                        manufacturer_cols[k_sku] = {bom_idx: supplier_sku}

        # Add supplier columns to dataset
        add_columns_to_dataset(manufacturer_cols, len(bom_items))

    data = dataset.export(fmt)

    filename = f"{part.full_name}_BOM.{fmt}"

    return DownloadFile(data, filename)
Example #17
0
    def clean(self):
        """
        Check validity of this BuildItem instance.
        The following checks are performed:

        - StockItem.part must be in the BOM of the Part object referenced by Build
        - Allocation quantity cannot exceed available quantity
        """

        self.validate_unique()

        super().clean()

        try:

            # If the 'part' is trackable, then the 'install_into' field must be set!
            if self.stock_item.part and self.stock_item.part.trackable and not self.install_into:
                raise ValidationError(
                    _('Build item must specify a build output, as master part is marked as trackable'
                      ))

            # Allocated quantity cannot exceed available stock quantity
            if self.quantity > self.stock_item.quantity:

                q = normalize(self.quantity)
                a = normalize(self.stock_item.quantity)

                raise ValidationError({
                    'quantity':
                    _(f'Allocated quantity ({q}) must not execed available stock quantity ({a})'
                      )
                })

            # Allocated quantity cannot cause the stock item to be over-allocated
            available = decimal.Decimal(self.stock_item.quantity)
            allocated = decimal.Decimal(self.stock_item.allocation_count())
            quantity = decimal.Decimal(self.quantity)

            if available - allocated + quantity < quantity:
                raise ValidationError(
                    {'quantity': _('Stock item is over-allocated')})

            # Allocated quantity must be positive
            if self.quantity <= 0:
                raise ValidationError({
                    'quantity':
                    _('Allocation quantity must be greater than zero'),
                })

            # Quantity must be 1 for serialized stock
            if self.stock_item.serialized and not self.quantity == 1:
                raise ValidationError(
                    {'quantity': _('Quantity must be 1 for serialized stock')})

        except (StockModels.StockItem.DoesNotExist,
                PartModels.Part.DoesNotExist):
            pass
        """
        Attempt to find the "BomItem" which links this BuildItem to the build.

        - If a BomItem is already set, and it is valid, then we are ok!
        """

        bom_item_valid = False

        if self.bom_item and self.build:
            """
            A BomItem object has already been assigned. This is valid if:

            a) It points to the same "part" as the referenced build
            b) Either:
                i) The sub_part points to the same part as the referenced StockItem
                ii) The BomItem allows variants and the part referenced by the StockItem
                    is a variant of the sub_part referenced by the BomItem
                iii) The Part referenced by the StockItem is a valid substitute for the BomItem
            """

            if self.build.part == self.bom_item.part:
                bom_item_valid = self.bom_item.is_stock_item_valid(
                    self.stock_item)

            elif self.bom_item.inherited:
                if self.build.part in self.bom_item.part.get_descendants(
                        include_self=False):
                    bom_item_valid = self.bom_item.is_stock_item_valid(
                        self.stock_item)

        # If the existing BomItem is *not* valid, try to find a match
        if not bom_item_valid:

            if self.build and self.stock_item:
                ancestors = self.stock_item.part.get_ancestors(
                    include_self=True, ascending=True)

                for idx, ancestor in enumerate(ancestors):

                    try:
                        bom_item = PartModels.BomItem.objects.get(
                            part=self.build.part, sub_part=ancestor)
                    except PartModels.BomItem.DoesNotExist:
                        continue

                    # A matching BOM item has been found!
                    if idx == 0 or bom_item.allow_variants:
                        bom_item_valid = True
                        self.bom_item = bom_item
                        break

        # BomItem did not exist or could not be validated.
        # Search for a new one
        if not bom_item_valid:

            raise ValidationError(
                {'stock_item': _("Selected stock item not found in BOM")})