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)
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)
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)
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'))
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
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
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
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)
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
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)
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
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() }
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
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
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
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)
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")})