InvenTree/InvenTree/part/models.py

628 lines
20 KiB
Python
Raw Normal View History

2019-04-27 12:18:07 +00:00
"""
Part database model definitions
"""
2018-04-17 08:11:34 +00:00
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
2018-04-17 08:11:34 +00:00
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.db import models
2017-04-16 07:05:02 +00:00
from django.core.validators import MinValueValidator
from django.db.models.signals import pre_delete
from django.dispatch import receiver
2019-05-02 10:57:53 +00:00
from InvenTree import helpers
2018-04-17 08:11:34 +00:00
from InvenTree.models import InvenTreeTree
from company.models import Company
2017-03-28 12:17:56 +00:00
2018-04-23 11:18:35 +00:00
2017-03-28 12:31:41 +00:00
class PartCategory(InvenTreeTree):
2017-03-27 11:55:21 +00:00
""" PartCategory provides hierarchical organization of Part objects.
"""
2017-03-29 12:19:53 +00:00
default_location = models.ForeignKey(
'stock.StockLocation', related_name="default_categories",
null=True, blank=True,
2019-05-04 11:57:43 +00:00
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"
2017-03-29 12:19:53 +00:00
@property
def partcount(self):
""" Return the total part count under this category
(including children of child categories)
"""
count = self.parts.count()
for child in self.children.all():
count += child.partcount
return count
2017-04-10 23:41:03 +00:00
@property
2018-04-16 13:26:02 +00:00
def has_parts(self):
2019-04-27 12:18:07 +00:00
""" True if there are any parts in this category """
2018-04-16 13:26:02 +00:00
return self.parts.count() > 0
2018-04-15 15:02:17 +00:00
@receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log')
def before_delete_part_category(sender, instance, using, **kwargs):
2019-04-27 12:18:07 +00:00
""" 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()
2017-04-10 23:41:03 +00:00
# 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.<img>
def rename_part_image(instance, filename):
2019-04-27 12:18:07 +00:00
""" 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_<n>_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)
2017-03-29 12:19:53 +00:00
class Part(models.Model):
""" Represents an abstract part
Parts can be "stocked" in multiple warehouses,
and can be combined to form other parts
2018-04-30 12:30:15 +00:00
"""
2017-03-29 12:19:53 +00:00
def get_absolute_url(self):
2019-04-24 14:59:34 +00:00
return reverse('part-detail', kwargs={'pk': self.id})
2017-04-01 02:31:48 +00:00
# Short name of the part
name = models.CharField(max_length=100, unique=True, blank=False, help_text='Part name (must be unique)')
2017-04-01 02:31:48 +00:00
# Longer description of the part (optional)
description = models.CharField(max_length=250, blank=False, help_text='Part description')
2017-04-01 02:31:48 +00:00
# Internal Part Number (optional)
# Potentially multiple parts map to the same internal IPN (variants?)
# So this does not have to be unique
2018-04-16 13:09:45 +00:00
IPN = models.CharField(max_length=100, blank=True, help_text='Internal Part Number')
2017-04-01 02:31:48 +00:00
# Provide a URL for an external link
URL = models.URLField(blank=True, help_text='Link to extenal URL')
2017-04-01 02:31:48 +00:00
# Part category - all parts must be assigned to a category
category = models.ForeignKey(PartCategory, related_name='parts',
null=True, blank=True,
2018-04-16 13:09:45 +00:00
on_delete=models.DO_NOTHING,
help_text='Part category')
2017-04-01 02:31:48 +00:00
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.
2019-05-04 11:57:43 +00:00
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 part
default_supplier = models.ForeignKey('part.SupplierPart',
on_delete=models.SET_NULL,
blank=True, null=True,
help_text='Default supplier part',
related_name='default_parts')
2017-04-01 02:31:48 +00:00
# Minimum "allowed" stock level
2018-04-16 13:09:45 +00:00
minimum_stock = models.PositiveIntegerField(default=0, validators=[MinValueValidator(0)], help_text='Minimum allowed stock level')
2017-04-01 02:31:48 +00:00
# Units of quantity for this part. Default is "pcs"
2017-03-28 06:49:01 +00:00
units = models.CharField(max_length=20, default="pcs", blank=True)
2017-04-01 02:31:48 +00:00
# Can this part be built from other parts?
2018-04-16 13:09:45 +00:00
buildable = models.BooleanField(default=False, help_text='Can this part be built from other parts?')
# Can this part be used to make other parts?
consumable = models.BooleanField(default=True, help_text='Can this part be used to build other parts?')
2017-04-01 02:31:48 +00:00
# Is this part "trackable"?
# Trackable parts can have unique instances
# which are assigned serial numbers (or batch numbers)
2017-04-01 02:31:48 +00:00
# and can have their movements tracked
2018-04-16 13:09:45 +00:00
trackable = models.BooleanField(default=False, help_text='Does this part have tracking for unique items?')
2017-03-29 12:19:53 +00:00
# Is this part "purchaseable"?
2018-04-16 13:09:45 +00:00
purchaseable = models.BooleanField(default=True, help_text='Can this part be purchased from external suppliers?')
2018-04-17 08:11:34 +00:00
# Can this part be sold to customers?
salable = models.BooleanField(default=False, help_text="Can this part be sold to customers?")
2019-04-28 13:00:38 +00:00
# Is this part active?
active = models.BooleanField(default=True, help_text='Is this part active?')
notes = models.TextField(blank=True)
def __str__(self):
return "{n} - {d}".format(n=self.name, d=self.description)
2017-03-29 12:19:53 +00:00
2019-05-02 10:57:53 +00:00
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,
}
2019-05-02 10:57:53 +00:00
)
class Meta:
verbose_name = "Part"
verbose_name_plural = "Parts"
2017-03-29 12:19:53 +00:00
@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 need_to_restock(self):
""" Return True if this part needs to be restocked
(either by purchasing or building).
2019-05-02 10:19:08 +00:00
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 self.available_stock
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):
2019-04-27 12:18:07 +00:00
""" Return true if any of this part is allocated:
- To another build
- To a customer order
"""
return sum([
self.allocated_build_count,
])
2018-04-30 12:30:15 +00:00
@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
"""
2017-03-29 12:19:53 +00:00
2018-04-30 12:30:15 +00:00
return sum([loc.quantity for loc in self.stock_entries])
2017-03-29 12:19:53 +00:00
@property
def has_bom(self):
return self.bom_count > 0
@property
def bom_count(self):
2018-04-16 13:26:02 +00:00
return self.bom_items.count()
@property
def used_in_count(self):
2018-04-16 13:26:02 +00:00
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
2018-04-16 13:26:02 +00:00
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)
2017-03-29 04:12:14 +00:00
2018-04-15 15:02:17 +00:00
def attach_file(instance, filename):
2019-04-27 12:18:07 +00:00
""" 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_<pk>_filename'
"""
# Construct a path to store a file attachment
return os.path.join('part_files', str(instance.part.id), filename)
2018-04-15 15:02:17 +00:00
class PartAttachment(models.Model):
""" A PartAttachment links a file to a part
Parts can have multiple files such as datasheets, etc
"""
part = models.ForeignKey(Part, on_delete=models.CASCADE,
related_name='attachments')
attachment = models.FileField(upload_to=attach_file, null=True, blank=True)
comment = models.CharField(max_length=100, blank=True, help_text='File comment')
@property
def basename(self):
return os.path.basename(self.attachment.name)
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
"""
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',
2019-04-16 12:32:43 +00:00
limit_choices_to={'buildable': 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',
2019-04-16 12:32:43 +00:00
limit_choices_to={'consumable': True})
# Quantity required
quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)])
2019-04-14 08:26:11 +00:00
# Note attached to this BOM line item
note = models.CharField(max_length=100, blank=True, help_text='Item notes')
def clean(self):
2019-04-27 12:18:07 +00:00
""" 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 "{par} -> {child} ({n})".format(
par=self.part.name,
child=self.sub_part.name,
n=self.quantity)
2018-04-22 12:38:35 +00:00
class SupplierPart(models.Model):
""" Represents a unique part as provided by a Supplier
Each SupplierPart is identified by a MPN (Manufacturer Part Number)
2019-04-27 12:18:07 +00:00
Each SupplierPart is also linked to a Part object.
A Part may be available from multiple suppliers
"""
def get_absolute_url(self):
return reverse('supplier-part-detail', kwargs={'pk': self.id})
class Meta:
unique_together = ('part', 'supplier', 'SKU')
# Link to an actual part
# The part will have a field 'supplier_parts' which links to the supplier part options
part = models.ForeignKey(Part, on_delete=models.CASCADE,
related_name='supplier_parts',
2019-04-16 14:21:11 +00:00
limit_choices_to={'purchaseable': True},
)
supplier = models.ForeignKey(Company, on_delete=models.CASCADE,
related_name='parts',
limit_choices_to={'is_supplier': True}
)
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 attached to this BOM line item
note = models.CharField(max_length=100, blank=True, help_text='Notes')
# Default price for a single unit
single_price = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], help_text='Price for single quantity')
# Base charge added to order independent of quantity e.g. "Reeling Fee"
base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], help_text='Minimum charge (e.g. stocking fee)')
# packaging that the part is supplied in, e.g. "Reel"
packaging = models.CharField(max_length=50, blank=True, help_text='Part packaging')
# multiple that the part is provided in
2019-04-16 14:48:01 +00:00
multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text='Order multiple')
# Mimumum number required to order
2019-04-16 14:48:01 +00:00
minimum = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text='Minimum order quantity (MOQ)')
# lead time for parts that cannot be delivered immediately
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.
2019-04-27 12:18:07 +00:00
- If no price breaks available, use the single_price field
- Don't forget to add in flat-fee cost (base_cost field)
2019-04-16 21:49:46 +00:00
- 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):
2019-04-28 01:46:53 +00:00
return "{supplier} ({sku})".format(
sku=self.SKU,
supplier=self.supplier.name)
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)
"""
part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='price_breaks')
2019-04-16 21:49:46 +00:00
# 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)