mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
7644617af0
Allows for easier searching in drop-downs for ForeignKeys
458 lines
14 KiB
Python
458 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
|
from __future__ import unicode_literals
|
|
|
|
import os
|
|
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.core.exceptions import ValidationError
|
|
|
|
from django.db import models
|
|
from django.db.models import Sum
|
|
from django.core.validators import MinValueValidator
|
|
|
|
from django.db.models.signals import pre_delete
|
|
from django.dispatch import receiver
|
|
|
|
from InvenTree.models import InvenTreeTree
|
|
from company.models import Company
|
|
|
|
|
|
class PartCategory(InvenTreeTree):
|
|
""" PartCategory provides hierarchical organization of Part objects.
|
|
"""
|
|
|
|
def get_absolute_url(self):
|
|
return '/part/category/{id}/'.format(id=self.id)
|
|
|
|
class Meta:
|
|
verbose_name = "Part Category"
|
|
verbose_name_plural = "Part Categories"
|
|
|
|
@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
|
|
|
|
@property
|
|
def has_parts(self):
|
|
return self.parts.count() > 0
|
|
|
|
|
|
@receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log')
|
|
def before_delete_part_category(sender, instance, using, **kwargs):
|
|
|
|
# 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.<img>
|
|
def rename_part_image(instance, filename):
|
|
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):
|
|
""" Represents an abstract part
|
|
Parts can be "stocked" in multiple warehouses,
|
|
and can be combined to form other parts
|
|
"""
|
|
|
|
def get_absolute_url(self):
|
|
return '/part/{id}/'.format(id=self.id)
|
|
|
|
# Short name of the part
|
|
name = models.CharField(max_length=100, unique=True, help_text='Part name (must be unique)')
|
|
|
|
# Longer description of the part (optional)
|
|
description = models.CharField(max_length=250, help_text='Part description')
|
|
|
|
# Internal Part Number (optional)
|
|
# Potentially multiple parts map to the same internal IPN (variants?)
|
|
# So this does not have to be unique
|
|
IPN = models.CharField(max_length=100, blank=True, help_text='Internal Part Number')
|
|
|
|
# Provide a URL for an external link
|
|
URL = models.URLField(blank=True, help_text='Link to extenal URL')
|
|
|
|
# Part category - all parts must be assigned to a category
|
|
category = models.ForeignKey(PartCategory, related_name='parts',
|
|
null=True, blank=True,
|
|
on_delete=models.DO_NOTHING,
|
|
help_text='Part category')
|
|
|
|
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')
|
|
|
|
# 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')
|
|
|
|
# Minimum "allowed" stock level
|
|
minimum_stock = models.PositiveIntegerField(default=0, validators=[MinValueValidator(0)], help_text='Minimum allowed stock level')
|
|
|
|
# Units of quantity for this part. Default is "pcs"
|
|
units = models.CharField(max_length=20, default="pcs", blank=True)
|
|
|
|
# Can this part be built?
|
|
buildable = models.BooleanField(default=False, help_text='Can this part be built from 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?")
|
|
|
|
notes = models.TextField(blank=True)
|
|
|
|
def __str__(self):
|
|
return "{n} - {d}".format(n=self.name, d=self.description)
|
|
|
|
class Meta:
|
|
verbose_name = "Part"
|
|
verbose_name_plural = "Parts"
|
|
|
|
@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
|
|
"""
|
|
|
|
total = self.total_stock
|
|
|
|
total -= self.allocation_count
|
|
|
|
return max(total, 0)
|
|
|
|
@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):
|
|
""" 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()
|
|
|
|
@property
|
|
def supplier_count(self):
|
|
# Return the number of supplier parts available for this part
|
|
return self.supplier_parts.count()
|
|
|
|
"""
|
|
@property
|
|
def projects(self):
|
|
" Return a list of unique projects that this part is associated with.
|
|
A part may be used in zero or more projects.
|
|
"
|
|
|
|
project_ids = set()
|
|
project_parts = self.projectpart_set.all()
|
|
|
|
projects = []
|
|
|
|
for pp in project_parts:
|
|
if pp.project.id not in project_ids:
|
|
project_ids.add(pp.project.id)
|
|
projects.append(pp.project)
|
|
|
|
return projects
|
|
"""
|
|
|
|
|
|
def attach_file(instance, filename):
|
|
|
|
base = 'part_files'
|
|
|
|
# TODO - For a new PartAttachment object, PK is NULL!!
|
|
|
|
# Prefix the attachment ID to the filename
|
|
fn = "{id}_{fn}".format(id=instance.pk, fn=filename)
|
|
|
|
return os.path.join(base, fn)
|
|
|
|
|
|
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)
|
|
|
|
|
|
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 '/part/bom/{id}/'.format(id=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})
|
|
|
|
# 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')
|
|
|
|
# Quantity required
|
|
quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)])
|
|
|
|
def clean(self):
|
|
|
|
# A part cannot refer to itself in its BOM
|
|
if self.part == self.sub_part:
|
|
raise ValidationError(_('A part cannot contain itself as a BOM item'))
|
|
|
|
for item in self.sub_part.bom_items.all():
|
|
if self.part == item.sub_part:
|
|
raise ValidationError(_("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)
|
|
|
|
|
|
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
|
|
"""
|
|
|
|
def get_absolute_url(self):
|
|
return "/supplier-part/{id}/".format(id=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')
|
|
|
|
supplier = models.ForeignKey(Company, on_delete=models.CASCADE,
|
|
related_name='parts')
|
|
|
|
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)
|
|
|
|
# Default price for a single unit
|
|
single_price = models.DecimalField(max_digits=10, decimal_places=3, default=0)
|
|
|
|
# Base charge added to order independent of quantity e.g. "Reeling Fee"
|
|
base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0)
|
|
|
|
# packaging that the part is supplied in, e.g. "Reel"
|
|
packaging = models.CharField(max_length=50, blank=True)
|
|
|
|
# multiple that the part is provided in
|
|
multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)])
|
|
|
|
# Mimumum number required to order
|
|
minimum = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)])
|
|
|
|
# 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)
|
|
|
|
def __str__(self):
|
|
return "{sku} - {supplier}".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')
|
|
quantity = models.PositiveIntegerField(validators=[MinValueValidator(0)])
|
|
cost = models.DecimalField(max_digits=10, decimal_places=3)
|
|
|
|
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)
|