""" Part database model definitions """ # -*- coding: utf-8 -*- from __future__ import unicode_literals import os import math import tablib from django.utils.translation import gettext_lazy as _ from django.core.exceptions import ValidationError from django.urls import reverse from django.conf import settings from django.db import models from django.core.validators import MinValueValidator from django.contrib.staticfiles.templatetags.staticfiles import static from django.contrib.auth.models import User from django.db.models.signals import pre_delete from django.dispatch import receiver from InvenTree import helpers from InvenTree import validators from InvenTree.models import InvenTreeTree from company.models import Company class PartCategory(InvenTreeTree): """ PartCategory provides hierarchical organization of Part objects. """ default_location = models.ForeignKey( 'stock.StockLocation', related_name="default_categories", null=True, blank=True, on_delete=models.SET_NULL, help_text='Default location for parts in this category' ) def get_absolute_url(self): return reverse('category-detail', kwargs={'pk': self.id}) class Meta: verbose_name = "Part Category" verbose_name_plural = "Part Categories" @property def item_count(self): return self.partcount @property def partcount(self): """ Return the total part count under this category (including children of child categories) """ return len(Part.objects.filter(category__in=self.getUniqueChildren(), active=True)) @property def has_parts(self): """ True if there are any parts in this category """ return self.parts.count() > 0 @receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log') def before_delete_part_category(sender, instance, using, **kwargs): """ Receives before_delete signal for PartCategory object Before deleting, update child Part and PartCategory objects: - For each child category, set the parent to the parent of *this* category - For each part, set the 'category' to the parent of *this* category """ # Update each part in this category to point to the parent category for part in instance.parts.all(): part.category = instance.parent part.save() # Update each child category for child in instance.children.all(): child.parent = instance.parent child.save() # Function to automatically rename a part image on upload # Format: part_pk. def rename_part_image(instance, filename): """ Function for renaming a part image file Args: instance: Instance of a Part object filename: Name of original uploaded file Returns: Cleaned filename in format part__img """ base = 'part_images' if filename.count('.') > 0: ext = filename.split('.')[-1] else: ext = '' fn = 'part_{pk}_img'.format(pk=instance.pk) if ext: fn += '.' + ext return os.path.join(base, fn) class Part(models.Model): """ The Part object represents an abstract part, the 'concept' of an actual entity. An actual physical instance of a Part is a StockItem which is treated separately. Parts can be used to create other parts (as part of a Bill of Materials or BOM). Attributes: name: Brief name for this part variant: Optional variant number for this part - Must be unique for the part name description: Longer form description of the part category: The PartCategory to which this part belongs IPN: Internal part number (optional) URL: Link to an external page with more information about this part (e.g. internal Wiki) image: Image of this part default_location: Where the item is normally stored (may be null) default_supplier: The default SupplierPart which should be used to procure and stock this part minimum_stock: Minimum preferred quantity to keep in stock units: Units of measure for this part (default='pcs') salable: Can this part be sold to customers? buildable: Can this part be build from other parts? consumable: Can this part be used to make other parts? purchaseable: Can this part be purchased from suppliers? trackable: Trackable parts can have unique serial numbers assigned, etc, etc active: Is this part active? Parts are deactivated instead of being deleted notes: Additional notes field for this part """ class Meta: verbose_name = "Part" verbose_name_plural = "Parts" unique_together = [ ('name', 'variant') ] def __str__(self): return "{n} - {d}".format(n=self.long_name, d=self.description) @property def long_name(self): name = self.name if self.variant: name += " | " + self.variant return name def get_absolute_url(self): """ Return the web URL for viewing this part """ return reverse('part-detail', kwargs={'pk': self.id}) def get_image_url(self): """ Return the URL of the image for this part """ if self.image: return os.path.join(settings.MEDIA_URL, str(self.image.url)) else: return static('/img/blank_image.png') name = models.CharField(max_length=100, blank=False, help_text='Part name', validators=[validators.validate_part_name] ) variant = models.CharField(max_length=32, blank=True, help_text='Part variant or revision code') description = models.CharField(max_length=250, blank=False, help_text='Part description') category = models.ForeignKey(PartCategory, related_name='parts', null=True, blank=True, on_delete=models.DO_NOTHING, help_text='Part category') IPN = models.CharField(max_length=100, blank=True, help_text='Internal Part Number') URL = models.URLField(blank=True, help_text='Link to extenal URL') image = models.ImageField(upload_to=rename_part_image, max_length=255, null=True, blank=True) default_location = models.ForeignKey('stock.StockLocation', on_delete=models.SET_NULL, blank=True, null=True, help_text='Where is this item normally stored?', related_name='default_parts') def get_default_location(self): """ Get the default location for a Part (may be None). If the Part does not specify a default location, look at the Category this part is in. The PartCategory object may also specify a default stock location """ if self.default_location: return self.default_location elif self.category: # Traverse up the category tree until we find a default location cat = self.category while cat: if cat.default_location: return cat.default_location else: cat = cat.parent # Default case - no default category found return None default_supplier = models.ForeignKey('part.SupplierPart', on_delete=models.SET_NULL, blank=True, null=True, help_text='Default supplier part', related_name='default_parts') minimum_stock = models.PositiveIntegerField(default=0, validators=[MinValueValidator(0)], help_text='Minimum allowed stock level') units = models.CharField(max_length=20, default="pcs", blank=True, help_text='Stock keeping units for this part') buildable = models.BooleanField(default=False, help_text='Can this part be built from other parts?') consumable = models.BooleanField(default=True, help_text='Can this part be used to build other parts?') # Is this part "trackable"? # Trackable parts can have unique instances # which are assigned serial numbers (or batch numbers) # and can have their movements tracked trackable = models.BooleanField(default=False, help_text='Does this part have tracking for unique items?') # Is this part "purchaseable"? purchaseable = models.BooleanField(default=True, help_text='Can this part be purchased from external suppliers?') # Can this part be sold to customers? salable = models.BooleanField(default=False, help_text="Can this part be sold to customers?") # Is this part active? active = models.BooleanField(default=True, help_text='Is this part active?') notes = models.TextField(blank=True) def format_barcode(self): """ Return a JSON string for formatting a barcode for this Part object """ return helpers.MakeBarcode( "Part", self.id, reverse('api-part-detail', kwargs={'pk': self.id}), { 'name': self.name, } ) @property def category_path(self): if self.category: return self.category.pathstring return '' @property def available_stock(self): """ Return the total available stock. - This subtracts stock which is already allocated to builds """ total = self.total_stock total -= self.allocation_count return total def isStarredBy(self, user): """ Return True if this part has been starred by a particular user """ try: PartStar.objects.get(part=self, user=user) return True except PartStar.DoesNotExist: return False def need_to_restock(self): """ Return True if this part needs to be restocked (either by purchasing or building). If the allocated_stock exceeds the total_stock, then we need to restock. """ return (self.total_stock - self.allocation_count) < self.minimum_stock @property def can_build(self): """ Return the number of units that can be build with available stock """ # If this part does NOT have a BOM, result is simply the currently available stock if not self.has_bom: return 0 total = None # Calculate the minimum number of parts that can be built using each sub-part for item in self.bom_items.all(): stock = item.sub_part.available_stock n = int(1.0 * stock / item.quantity) if total is None or n < total: total = n return max(total, 0) @property def active_builds(self): """ Return a list of outstanding builds. Builds marked as 'complete' or 'cancelled' are ignored """ return [b for b in self.builds.all() if b.is_active] @property def inactive_builds(self): """ Return a list of inactive builds """ return [b for b in self.builds.all() if not b.is_active] @property def quantity_being_built(self): """ Return the current number of parts currently being built """ return sum([b.quantity for b in self.active_builds]) @property def build_allocation(self): """ Return list of builds to which this part is allocated """ builds = [] for item in self.used_in.all(): for build in item.part.active_builds: b = {} b['build'] = build b['quantity'] = item.quantity * build.quantity builds.append(b) return builds @property def allocated_build_count(self): """ Return the total number of this that are allocated for builds """ return sum([a['quantity'] for a in self.build_allocation]) @property def allocation_count(self): """ Return true if any of this part is allocated: - To another build - To a customer order """ return sum([ self.allocated_build_count, ]) @property def stock_entries(self): return [loc for loc in self.locations.all() if loc.in_stock] @property def total_stock(self): """ Return the total stock quantity for this part. Part may be stored in multiple locations """ return sum([loc.quantity for loc in self.stock_entries]) @property def has_bom(self): return self.bom_count > 0 @property def bom_count(self): return self.bom_items.count() @property def used_in_count(self): return self.used_in.count() def required_parts(self): parts = [] for bom in self.bom_items.all(): parts.append(bom.sub_part) return parts @property def supplier_count(self): # Return the number of supplier parts available for this part return self.supplier_parts.count() def export_bom(self, **kwargs): data = tablib.Dataset(headers=[ 'Part', 'Description', 'Quantity', 'Note', ]) for it in self.bom_items.all(): line = [] line.append(it.sub_part.name) line.append(it.sub_part.description) line.append(it.quantity) line.append(it.note) data.append(line) file_format = kwargs.get('format', 'csv').lower() return data.export(file_format) def attach_file(instance, filename): """ Function for storing a file for a PartAttachment Args: instance: Instance of a PartAttachment object filename: name of uploaded file Returns: path to store file, format: 'part_file__filename' """ # Construct a path to store a file attachment return os.path.join('part_files', str(instance.part.id), filename) class PartAttachment(models.Model): """ A PartAttachment links a file to a part Parts can have multiple files such as datasheets, etc Attributes: part: Link to a Part object attachment: File comment: String descriptor for the attachment """ part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='attachments') attachment = models.FileField(upload_to=attach_file, null=True, blank=True, help_text='Select file to attach') comment = models.CharField(max_length=100, blank=True, help_text='File comment') @property def basename(self): return os.path.basename(self.attachment.name) class PartStar(models.Model): """ A PartStar object creates a relationship between a User and a Part. It is used to designate a Part as 'starred' (or favourited) for a given User, so that the user can track a list of their favourite parts. Attributes: part: Link to a Part object user: Link to a User object """ part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='starred_users') user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='starred_parts') class Meta: unique_together = ['part', 'user'] class BomItem(models.Model): """ A BomItem links a part to its component items. A part can have a BOM (bill of materials) which defines which parts are required (and in what quatity) to make it. Attributes: part: Link to the parent part (the part that will be produced) sub_part: Link to the child part (the part that will be consumed) quantity: Number of 'sub_parts' consumed to produce one 'part' note: Note field for this BOM item """ def get_absolute_url(self): return reverse('bom-item-detail', kwargs={'pk': self.id}) # A link to the parent part # Each part will get a reverse lookup field 'bom_items' part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='bom_items', limit_choices_to={ 'buildable': True, 'active': True, }) # A link to the child item (sub-part) # Each part will get a reverse lookup field 'used_in' sub_part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='used_in', limit_choices_to={ 'consumable': True, 'active': True }) # Quantity required quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)]) # Note attached to this BOM line item note = models.CharField(max_length=100, blank=True, help_text='Item notes') def clean(self): """ Check validity of the BomItem model. Performs model checks beyond simple field validation. - A part cannot refer to itself in its BOM - A part cannot refer to a part which refers to it """ # A part cannot refer to itself in its BOM if self.part == self.sub_part: raise ValidationError({'sub_part': _('Part cannot be added to its own Bill of Materials')}) # Test for simple recursion for item in self.sub_part.bom_items.all(): if self.part == item.sub_part: raise ValidationError({'sub_part': _("Part '{p1}' is used in BOM for '{p2}' (recursive)".format(p1=str(self.part), p2=str(self.sub_part)))}) class Meta: verbose_name = "BOM Item" # Prevent duplication of parent/child rows unique_together = ('part', 'sub_part') def __str__(self): return "{n} x {child} to make {parent}".format( parent=self.part.name, child=self.sub_part.name, n=self.quantity) class SupplierPart(models.Model): """ Represents a unique part as provided by a Supplier Each SupplierPart is identified by a MPN (Manufacturer Part Number) Each SupplierPart is also linked to a Part object. A Part may be available from multiple suppliers Attributes: part: Link to the master Part supplier: Company that supplies this SupplierPart object SKU: Stock keeping unit (supplier part number) manufacturer: Manufacturer name MPN: Manufacture part number URL: Link to external website for this part description: Descriptive notes field note: Longer form note field single_price: Default price for a single unit base_cost: Base charge added to order independent of quantity e.g. "Reeling Fee" multiple: Multiple that the part is provided in minimum: MOQ (minimum order quantity) required for purchase lead_time: Supplier lead time packaging: packaging that the part is supplied in, e.g. "Reel" """ def get_absolute_url(self): return reverse('supplier-part-detail', kwargs={'pk': self.id}) class Meta: unique_together = ('part', 'supplier', 'SKU') part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='supplier_parts', limit_choices_to={'purchaseable': True}, help_text='Select part', ) supplier = models.ForeignKey(Company, on_delete=models.CASCADE, related_name='parts', limit_choices_to={'is_supplier': True}, help_text='Select supplier', ) SKU = models.CharField(max_length=100, help_text='Supplier stock keeping unit') manufacturer = models.CharField(max_length=100, blank=True, help_text='Manufacturer') MPN = models.CharField(max_length=100, blank=True, help_text='Manufacturer part number') URL = models.URLField(blank=True) description = models.CharField(max_length=250, blank=True, help_text='Supplier part description') note = models.CharField(max_length=100, blank=True, help_text='Notes') single_price = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], help_text='Price for single quantity') base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], help_text='Minimum charge (e.g. stocking fee)') packaging = models.CharField(max_length=50, blank=True, help_text='Part packaging') multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text='Order multiple') minimum = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text='Minimum order quantity (MOQ)') lead_time = models.DurationField(blank=True, null=True) @property def manufacturer_string(self): items = [] if self.manufacturer: items.append(self.manufacturer) if self.MPN: items.append(self.MPN) return ' | '.join(items) @property def has_price_breaks(self): return self.price_breaks.count() > 0 def get_price(self, quantity, moq=True, multiples=True): """ Calculate the supplier price based on quantity price breaks. - If no price breaks available, use the single_price field - 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 """ # Order multiples if multiples: quantity = int(math.ceil(quantity / self.multipe) * self.multiple) # Minimum ordering requirement if moq and self.minimum > quantity: quantity = self.minimum pb_found = False pb_quantity = -1 pb_cost = 0.0 for pb in self.price_breaks.all(): # Ignore this pricebreak! 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 pb_cost = pb.cost # No appropriate price-break found - use the single cost! if pb_found: cost = pb_cost * quantity else: cost = self.single_price * quantity return cost + self.base_cost def __str__(self): s = "{supplier} ({sku})".format( sku=self.SKU, supplier=self.supplier.name) if self.manufacturer_string: s = s + ' - ' + self.manufacturer_string return s class SupplierPriceBreak(models.Model): """ Represents a quantity price break for a SupplierPart. - Suppliers can offer discounts at larger quantities - SupplierPart(s) may have zero-or-more associated SupplierPriceBreak(s) Attributes: part: Link to a SupplierPart object that this price break applies to quantity: Quantity required for price break cost: Cost at specified quantity """ part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='price_breaks') # At least 2 units are required for a 'price break' - Otherwise, just use single-price! quantity = models.PositiveIntegerField(validators=[MinValueValidator(2)]) cost = models.DecimalField(max_digits=10, decimal_places=3, validators=[MinValueValidator(0)]) class Meta: unique_together = ("part", "quantity") def __str__(self): return "{mpn} - {cost}{currency} @ {quan}".format( mpn=self.part.MPN, cost=self.cost, currency=self.currency if self.currency else '', quan=self.quantity)