2019-04-27 12:18:07 +00:00
|
|
|
"""
|
|
|
|
Part database model definitions
|
|
|
|
"""
|
|
|
|
|
2018-04-17 08:11:34 +00:00
|
|
|
# -*- coding: utf-8 -*-
|
2017-03-25 12:07:43 +00:00
|
|
|
from __future__ import unicode_literals
|
2018-04-17 08:11:34 +00:00
|
|
|
|
|
|
|
import os
|
|
|
|
|
2019-04-17 13:52:15 +00:00
|
|
|
import tablib
|
|
|
|
|
2018-04-27 13:23:44 +00:00
|
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from django.core.exceptions import ValidationError
|
2019-04-24 14:28:40 +00:00
|
|
|
from django.urls import reverse
|
2019-05-08 05:25:28 +00:00
|
|
|
from django.conf import settings
|
2018-04-27 13:23:44 +00:00
|
|
|
|
2019-05-13 11:54:52 +00:00
|
|
|
from django.core.files.base import ContentFile
|
2019-05-12 02:53:56 +00:00
|
|
|
from django.db import models, transaction
|
2019-05-20 11:47:30 +00:00
|
|
|
from django.db.models import Sum
|
2019-05-20 21:57:44 +00:00
|
|
|
from django.db.models import prefetch_related_objects
|
2017-04-16 07:05:02 +00:00
|
|
|
from django.core.validators import MinValueValidator
|
2017-03-25 12:07:43 +00:00
|
|
|
|
2019-05-08 14:39:51 +00:00
|
|
|
from django.contrib.staticfiles.templatetags.staticfiles import static
|
2019-05-04 22:46:23 +00:00
|
|
|
from django.contrib.auth.models import User
|
2018-04-14 10:33:53 +00:00
|
|
|
from django.db.models.signals import pre_delete
|
|
|
|
from django.dispatch import receiver
|
|
|
|
|
2019-05-12 06:27:50 +00:00
|
|
|
from datetime import datetime
|
2019-05-11 00:36:24 +00:00
|
|
|
from fuzzywuzzy import fuzz
|
2019-05-12 02:42:06 +00:00
|
|
|
import hashlib
|
2019-05-11 00:36:24 +00:00
|
|
|
|
2019-05-02 10:57:53 +00:00
|
|
|
from InvenTree import helpers
|
2019-05-10 12:52:06 +00:00
|
|
|
from InvenTree import validators
|
2018-04-17 08:11:34 +00:00
|
|
|
from InvenTree.models import InvenTreeTree
|
2019-05-18 08:04:25 +00:00
|
|
|
|
2019-06-10 12:31:19 +00:00
|
|
|
from InvenTree.status_codes import BuildStatus, StockStatus, OrderStatus
|
2019-06-04 13:38:52 +00:00
|
|
|
|
2019-05-18 08:04:25 +00:00
|
|
|
from company.models import SupplierPart
|
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.
|
2019-05-14 07:30:24 +00:00
|
|
|
|
|
|
|
Attributes:
|
|
|
|
name: Name of this category
|
|
|
|
parent: Parent category
|
|
|
|
default_location: Default storage location for parts in this category or child categories
|
|
|
|
default_keywords: Default keywords for parts created in this category
|
2017-03-27 11:55:21 +00:00
|
|
|
"""
|
2017-03-29 12:19:53 +00:00
|
|
|
|
2019-05-04 09:00:11 +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,
|
2019-05-04 09:00:11 +00:00
|
|
|
help_text='Default location for parts in this category'
|
|
|
|
)
|
|
|
|
|
2019-05-14 07:30:24 +00:00
|
|
|
default_keywords = models.CharField(blank=True, max_length=250, help_text='Default keywords for parts in this category')
|
|
|
|
|
2018-04-15 01:25:57 +00:00
|
|
|
def get_absolute_url(self):
|
2019-04-24 14:28:40 +00:00
|
|
|
return reverse('category-detail', kwargs={'pk': self.id})
|
2018-04-15 01:25:57 +00:00
|
|
|
|
2017-03-27 10:03:46 +00:00
|
|
|
class Meta:
|
|
|
|
verbose_name = "Part Category"
|
|
|
|
verbose_name_plural = "Part Categories"
|
2017-03-29 12:19:53 +00:00
|
|
|
|
2019-05-09 10:30:23 +00:00
|
|
|
@property
|
|
|
|
def item_count(self):
|
2019-06-17 14:09:42 +00:00
|
|
|
return self.partcount()
|
2019-05-09 10:30:23 +00:00
|
|
|
|
2019-06-17 13:52:49 +00:00
|
|
|
def partcount(self, cascade=True, active=True):
|
2018-04-14 13:05:36 +00:00
|
|
|
""" Return the total part count under this category
|
|
|
|
(including children of child categories)
|
|
|
|
"""
|
|
|
|
|
2019-06-17 14:59:54 +00:00
|
|
|
cats = [self.id]
|
|
|
|
|
2019-06-17 13:52:49 +00:00
|
|
|
if cascade:
|
2019-06-17 14:59:54 +00:00
|
|
|
cats += [cat for cat in self.getUniqueChildren()]
|
|
|
|
|
|
|
|
query = Part.objects.filter(category__in=cats)
|
2019-06-17 13:52:49 +00:00
|
|
|
|
|
|
|
if active:
|
|
|
|
query = query.filter(active=True)
|
|
|
|
|
|
|
|
return query.count()
|
2018-04-14 13:05:36 +00:00
|
|
|
|
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-14 10:33:53 +00:00
|
|
|
|
2018-04-15 15:02:17 +00:00
|
|
|
|
2018-04-14 10:33:53 +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
|
|
|
|
"""
|
2018-04-14 10:33:53 +00:00
|
|
|
|
|
|
|
# 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
|
|
|
|
2018-04-15 01:25:57 +00:00
|
|
|
# Update each child category
|
|
|
|
for child in instance.children.all():
|
|
|
|
child.parent = instance.parent
|
|
|
|
child.save()
|
|
|
|
|
2018-04-14 04:11:46 +00:00
|
|
|
|
2018-04-14 07:44:22 +00:00
|
|
|
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
|
|
|
|
"""
|
|
|
|
|
2018-04-14 07:44:22 +00:00
|
|
|
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
|
|
|
|
2019-05-11 01:55:17 +00:00
|
|
|
def match_part_names(match, threshold=80, reverse=True, compare_length=False):
|
2019-05-11 00:36:24 +00:00
|
|
|
""" Return a list of parts whose name matches the search term using fuzzy search.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
match: Term to match against
|
|
|
|
threshold: Match percentage that must be exceeded (default = 65)
|
|
|
|
reverse: Ordering for search results (default = True - highest match is first)
|
2019-05-11 01:55:17 +00:00
|
|
|
compare_length: Include string length checks
|
2019-05-11 00:36:24 +00:00
|
|
|
|
|
|
|
Returns:
|
|
|
|
A sorted dict where each element contains the following key:value pairs:
|
|
|
|
- 'part' : The matched part
|
|
|
|
- 'ratio' : The matched ratio
|
|
|
|
"""
|
|
|
|
|
2019-05-11 01:55:17 +00:00
|
|
|
match = str(match).strip().lower()
|
|
|
|
|
|
|
|
if len(match) == 0:
|
|
|
|
return []
|
|
|
|
|
2019-05-11 00:36:24 +00:00
|
|
|
parts = Part.objects.all()
|
|
|
|
|
|
|
|
matches = []
|
|
|
|
|
|
|
|
for part in parts:
|
2019-05-11 01:55:17 +00:00
|
|
|
compare = str(part.name).strip().lower()
|
|
|
|
|
|
|
|
if len(compare) == 0:
|
|
|
|
continue
|
|
|
|
|
2019-05-11 02:29:02 +00:00
|
|
|
ratio = fuzz.partial_token_sort_ratio(compare, match)
|
2019-05-11 01:55:17 +00:00
|
|
|
|
|
|
|
if compare_length:
|
|
|
|
# Also employ primitive length comparison
|
2019-05-26 02:16:57 +00:00
|
|
|
# TODO - Improve this somewhat...
|
2019-05-11 01:55:17 +00:00
|
|
|
l_min = min(len(match), len(compare))
|
|
|
|
l_max = max(len(match), len(compare))
|
2019-05-11 00:36:24 +00:00
|
|
|
|
2019-05-11 01:55:17 +00:00
|
|
|
ratio *= (l_min / l_max)
|
2019-05-11 00:36:24 +00:00
|
|
|
|
|
|
|
if ratio >= threshold:
|
|
|
|
matches.append({
|
|
|
|
'part': part,
|
|
|
|
'ratio': ratio
|
|
|
|
})
|
|
|
|
|
|
|
|
matches = sorted(matches, key=lambda item: item['ratio'], reverse=reverse)
|
|
|
|
|
|
|
|
return matches
|
|
|
|
|
|
|
|
|
2017-03-25 12:19:49 +00:00
|
|
|
class Part(models.Model):
|
2019-05-10 10:11:52 +00:00
|
|
|
""" 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
|
2019-05-10 12:17:13 +00:00
|
|
|
variant: Optional variant number for this part - Must be unique for the part name
|
2019-05-10 10:11:52 +00:00
|
|
|
category: The PartCategory to which this part belongs
|
2019-05-14 07:23:20 +00:00
|
|
|
description: Longer form description of the part
|
|
|
|
keywords: Optional keywords for improving part search results
|
2019-05-10 10:11:52 +00:00
|
|
|
IPN: Internal part number (optional)
|
2019-06-20 11:37:25 +00:00
|
|
|
revision: Part revision
|
2019-05-25 13:58:31 +00:00
|
|
|
is_template: If True, this part is a 'template' part and cannot be instantiated as a StockItem
|
2019-05-10 10:11:52 +00:00
|
|
|
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?
|
2019-06-02 09:46:30 +00:00
|
|
|
assembly: Can this part be build from other parts?
|
|
|
|
component: Can this part be used to make other parts?
|
2019-05-10 10:11:52 +00:00
|
|
|
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
|
2019-06-18 08:34:07 +00:00
|
|
|
virtual: Is this part "virtual"? e.g. a software product or similar
|
2019-05-10 10:11:52 +00:00
|
|
|
notes: Additional notes field for this part
|
2018-04-30 12:30:15 +00:00
|
|
|
"""
|
2017-03-29 12:19:53 +00:00
|
|
|
|
2019-05-10 12:17:13 +00:00
|
|
|
class Meta:
|
|
|
|
verbose_name = "Part"
|
|
|
|
verbose_name_plural = "Parts"
|
|
|
|
|
|
|
|
def __str__(self):
|
2019-05-12 02:16:04 +00:00
|
|
|
return "{n} - {d}".format(n=self.full_name, d=self.description)
|
2019-05-10 12:17:13 +00:00
|
|
|
|
|
|
|
@property
|
2019-05-12 02:16:04 +00:00
|
|
|
def full_name(self):
|
2019-05-12 02:29:16 +00:00
|
|
|
""" Format a 'full name' for this Part.
|
2019-05-12 02:16:04 +00:00
|
|
|
|
|
|
|
- IPN (if not null)
|
|
|
|
- Part name
|
|
|
|
- Part variant (if not null)
|
2019-05-12 02:29:16 +00:00
|
|
|
|
|
|
|
Elements are joined by the | character
|
2019-05-12 02:16:04 +00:00
|
|
|
"""
|
|
|
|
|
|
|
|
elements = []
|
|
|
|
|
|
|
|
if self.IPN:
|
|
|
|
elements.append(self.IPN)
|
|
|
|
|
|
|
|
elements.append(self.name)
|
|
|
|
|
2019-06-20 11:46:16 +00:00
|
|
|
if self.revision:
|
|
|
|
elements.append(self.revision)
|
|
|
|
|
2019-05-12 02:16:04 +00:00
|
|
|
return ' | '.join(elements)
|
2019-05-10 12:17:13 +00:00
|
|
|
|
2019-06-25 09:15:39 +00:00
|
|
|
def set_category(self, category):
|
|
|
|
|
|
|
|
if not type(category) == PartCategory:
|
|
|
|
raise ValidationError({
|
|
|
|
'category': _('Invalid object supplied to part.set_category')
|
|
|
|
})
|
|
|
|
|
|
|
|
try:
|
|
|
|
# Already in this category!
|
|
|
|
if category == self.category:
|
|
|
|
return
|
|
|
|
except PartCategory.DoesNotExist:
|
|
|
|
pass
|
|
|
|
|
|
|
|
self.category = category
|
|
|
|
self.save()
|
|
|
|
|
2018-04-14 15:18:12 +00:00
|
|
|
def get_absolute_url(self):
|
2019-05-08 05:25:28 +00:00
|
|
|
""" Return the web URL for viewing this part """
|
2019-04-24 14:59:34 +00:00
|
|
|
return reverse('part-detail', kwargs={'pk': self.id})
|
2018-04-14 15:18:12 +00:00
|
|
|
|
2019-05-08 05:25:28 +00:00
|
|
|
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:
|
2019-05-08 14:39:51 +00:00
|
|
|
return static('/img/blank_image.png')
|
2019-05-08 05:25:28 +00:00
|
|
|
|
2019-06-02 10:37:59 +00:00
|
|
|
def validate_unique(self, exclude=None):
|
2019-06-20 11:37:25 +00:00
|
|
|
""" Validate that a part is 'unique'.
|
|
|
|
Uniqueness is checked across the following (case insensitive) fields:
|
|
|
|
|
|
|
|
* Name
|
|
|
|
* IPN
|
|
|
|
* Revision
|
|
|
|
|
|
|
|
e.g. there can exist multiple parts with the same name, but only if
|
|
|
|
they have a different revision or internal part number.
|
|
|
|
|
|
|
|
"""
|
2019-06-02 10:37:59 +00:00
|
|
|
super().validate_unique(exclude)
|
|
|
|
|
|
|
|
# Part name uniqueness should be case insensitive
|
|
|
|
try:
|
2019-06-20 11:37:25 +00:00
|
|
|
parts = Part.objects.exclude(id=self.id).filter(
|
|
|
|
name__iexact=self.name,
|
2019-06-20 11:46:16 +00:00
|
|
|
IPN__iexact=self.IPN,
|
|
|
|
revision__iexact=self.revision)
|
2019-06-20 11:37:25 +00:00
|
|
|
|
|
|
|
if parts.exists():
|
|
|
|
msg = _("Part must be unique for name, IPN and revision")
|
2019-06-02 10:37:59 +00:00
|
|
|
raise ValidationError({
|
2019-06-20 11:37:25 +00:00
|
|
|
"name": msg,
|
|
|
|
"IPN": msg,
|
2019-06-20 11:46:16 +00:00
|
|
|
"revision": msg,
|
2019-06-02 10:37:59 +00:00
|
|
|
})
|
|
|
|
except Part.DoesNotExist:
|
|
|
|
pass
|
|
|
|
|
2019-05-25 12:43:47 +00:00
|
|
|
def clean(self):
|
|
|
|
""" Perform cleaning operations for the Part model """
|
|
|
|
|
2019-05-25 13:58:31 +00:00
|
|
|
if self.is_template and self.variant_of is not None:
|
2019-05-25 12:43:47 +00:00
|
|
|
raise ValidationError({
|
2019-05-25 13:58:31 +00:00
|
|
|
'is_template': _("Part cannot be a template part if it is a variant of another part"),
|
2019-05-25 12:43:47 +00:00
|
|
|
'variant_of': _("Part cannot be a variant of another part if it is already a template"),
|
|
|
|
})
|
|
|
|
|
2019-06-20 11:37:25 +00:00
|
|
|
name = models.CharField(max_length=100, blank=False,
|
|
|
|
help_text='Part name',
|
2019-05-10 12:52:06 +00:00
|
|
|
validators=[validators.validate_part_name]
|
|
|
|
)
|
2019-05-10 12:17:13 +00:00
|
|
|
|
2019-05-25 13:58:31 +00:00
|
|
|
is_template = models.BooleanField(default=False, help_text='Is this part a template part?')
|
2019-05-25 12:27:36 +00:00
|
|
|
|
|
|
|
variant_of = models.ForeignKey('part.Part', related_name='variants',
|
|
|
|
null=True, blank=True,
|
|
|
|
limit_choices_to={
|
2019-05-25 13:58:31 +00:00
|
|
|
'is_template': True,
|
2019-05-25 12:27:36 +00:00
|
|
|
'active': True,
|
|
|
|
},
|
|
|
|
on_delete=models.SET_NULL,
|
|
|
|
help_text='Is this part a variant of another part?')
|
|
|
|
|
2019-05-03 14:37:08 +00:00
|
|
|
description = models.CharField(max_length=250, blank=False, help_text='Part description')
|
2017-04-01 02:31:48 +00:00
|
|
|
|
2019-05-14 07:23:20 +00:00
|
|
|
keywords = models.CharField(max_length=250, blank=True, help_text='Part keywords to improve visibility in search results')
|
|
|
|
|
2018-04-14 04:11:46 +00:00
|
|
|
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
|
|
|
|
2019-05-10 10:11:52 +00:00
|
|
|
IPN = models.CharField(max_length=100, blank=True, help_text='Internal Part Number')
|
|
|
|
|
2019-06-20 11:46:16 +00:00
|
|
|
revision = models.CharField(max_length=100, blank=True, help_text='Part revision or version number')
|
|
|
|
|
2019-05-10 10:11:52 +00:00
|
|
|
URL = models.URLField(blank=True, help_text='Link to extenal URL')
|
|
|
|
|
2018-04-14 07:44:22 +00:00
|
|
|
image = models.ImageField(upload_to=rename_part_image, max_length=255, null=True, blank=True)
|
|
|
|
|
2018-04-17 08:23:24 +00:00
|
|
|
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')
|
|
|
|
|
2019-05-04 09:06:39 +00:00
|
|
|
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
|
2019-05-04 09:06:39 +00:00
|
|
|
"""
|
|
|
|
|
|
|
|
if self.default_location:
|
|
|
|
return self.default_location
|
|
|
|
elif self.category:
|
2019-05-04 11:56:18 +00:00
|
|
|
# Traverse up the category tree until we find a default location
|
|
|
|
cat = self.category
|
2019-05-04 09:06:39 +00:00
|
|
|
|
2019-05-04 11:56:18 +00:00
|
|
|
while cat:
|
|
|
|
if cat.default_location:
|
|
|
|
return cat.default_location
|
|
|
|
else:
|
|
|
|
cat = cat.parent
|
|
|
|
|
|
|
|
# Default case - no default category found
|
2019-05-04 09:06:39 +00:00
|
|
|
return None
|
|
|
|
|
2019-05-14 14:22:10 +00:00
|
|
|
def get_default_supplier(self):
|
|
|
|
""" Get the default supplier part for this part (may be None).
|
|
|
|
|
|
|
|
- If the part specifies a default_supplier, return that
|
|
|
|
- If there is only one supplier part available, return that
|
|
|
|
- Else, return None
|
|
|
|
"""
|
|
|
|
|
|
|
|
if self.default_supplier:
|
2019-06-11 13:37:32 +00:00
|
|
|
return self.default_supplier
|
2019-05-14 14:22:10 +00:00
|
|
|
|
|
|
|
if self.supplier_count == 1:
|
|
|
|
return self.supplier_parts.first()
|
|
|
|
|
|
|
|
# Default to None if there are multiple suppliers to choose from
|
|
|
|
return None
|
|
|
|
|
2019-05-18 08:04:25 +00:00
|
|
|
default_supplier = models.ForeignKey(SupplierPart,
|
2018-04-17 08:23:24 +00:00
|
|
|
on_delete=models.SET_NULL,
|
|
|
|
blank=True, null=True,
|
|
|
|
help_text='Default supplier part',
|
|
|
|
related_name='default_parts')
|
|
|
|
|
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
|
|
|
|
2019-05-08 13:32:57 +00:00
|
|
|
units = models.CharField(max_length=20, default="pcs", blank=True, help_text='Stock keeping units for this part')
|
2017-04-01 02:31:48 +00:00
|
|
|
|
2019-06-02 09:46:30 +00:00
|
|
|
assembly = models.BooleanField(default=False, verbose_name='Assembly', help_text='Can this part be built from other parts?')
|
2018-04-16 12:13:31 +00:00
|
|
|
|
2019-06-02 09:46:30 +00:00
|
|
|
component = models.BooleanField(default=True, verbose_name='Component', help_text='Can this part be used to build other parts?')
|
2019-04-15 14:01:15 +00:00
|
|
|
|
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
|
|
|
|
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-15 14:30:57 +00:00
|
|
|
|
2018-04-17 08:11:34 +00:00
|
|
|
salable = models.BooleanField(default=False, help_text="Can this part be sold to customers?")
|
|
|
|
|
2019-04-28 13:00:38 +00:00
|
|
|
active = models.BooleanField(default=True, help_text='Is this part active?')
|
|
|
|
|
2019-06-18 08:34:07 +00:00
|
|
|
virtual = models.BooleanField(default=False, help_text='Is this a virtual part, such as a software product or license?')
|
|
|
|
|
2018-04-17 15:44:55 +00:00
|
|
|
notes = models.TextField(blank=True)
|
|
|
|
|
2019-05-12 02:47:28 +00:00
|
|
|
bom_checksum = models.CharField(max_length=128, blank=True, help_text='Stored BOM checksum')
|
|
|
|
|
|
|
|
bom_checked_by = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True,
|
|
|
|
related_name='boms_checked')
|
|
|
|
|
|
|
|
bom_checked_date = models.DateField(blank=True, null=True)
|
|
|
|
|
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}),
|
2019-05-04 13:35:52 +00:00
|
|
|
{
|
|
|
|
'name': self.name,
|
|
|
|
}
|
2019-05-02 10:57:53 +00:00
|
|
|
)
|
|
|
|
|
2018-04-24 07:54:08 +00:00
|
|
|
@property
|
|
|
|
def category_path(self):
|
|
|
|
if self.category:
|
|
|
|
return self.category.pathstring
|
|
|
|
return ''
|
|
|
|
|
2018-04-15 14:30:57 +00:00
|
|
|
@property
|
2018-04-16 11:49:38 +00:00
|
|
|
def available_stock(self):
|
|
|
|
"""
|
|
|
|
Return the total available stock.
|
2019-05-02 10:18:34 +00:00
|
|
|
|
|
|
|
- This subtracts stock which is already allocated to builds
|
2018-04-16 11:49:38 +00:00
|
|
|
"""
|
|
|
|
|
2018-04-17 12:26:57 +00:00
|
|
|
total = self.total_stock
|
|
|
|
|
|
|
|
total -= self.allocation_count
|
|
|
|
|
2019-05-23 11:51:27 +00:00
|
|
|
return max(total, 0)
|
2019-05-02 10:18:34 +00:00
|
|
|
|
2019-06-11 13:37:32 +00:00
|
|
|
@property
|
|
|
|
def quantity_to_order(self):
|
|
|
|
""" Return the quantity needing to be ordered for this part. """
|
|
|
|
|
|
|
|
required = -1 * self.net_stock
|
|
|
|
return max(required, 0)
|
|
|
|
|
2019-06-11 11:58:20 +00:00
|
|
|
@property
|
|
|
|
def net_stock(self):
|
|
|
|
""" Return the 'net' stock. It takes into account:
|
|
|
|
|
|
|
|
- Stock on hand (total_stock)
|
|
|
|
- Stock on order (on_order)
|
|
|
|
- Stock allocated (allocation_count)
|
|
|
|
|
|
|
|
This number (unlike 'available_stock') can be negative.
|
|
|
|
"""
|
|
|
|
|
|
|
|
return self.total_stock - self.allocation_count + self.on_order
|
|
|
|
|
2019-05-05 00:54:21 +00:00
|
|
|
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
|
|
|
|
|
2019-05-02 10:18:34 +00:00
|
|
|
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,
|
2019-05-02 10:18:34 +00:00
|
|
|
then we need to restock.
|
|
|
|
"""
|
|
|
|
|
2019-06-10 13:05:14 +00:00
|
|
|
return (self.total_stock + self.on_order - self.allocation_count) < self.minimum_stock
|
2018-04-16 11:49:38 +00:00
|
|
|
|
|
|
|
@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:
|
2019-05-09 08:35:55 +00:00
|
|
|
return 0
|
2018-04-16 11:49:38 +00:00
|
|
|
|
|
|
|
total = None
|
|
|
|
|
2018-04-16 12:13:31 +00:00
|
|
|
# Calculate the minimum number of parts that can be built using each sub-part
|
2019-05-20 21:57:44 +00:00
|
|
|
for item in self.bom_items.all().prefetch_related('sub_part__stock_items'):
|
2018-04-16 11:49:38 +00:00
|
|
|
stock = item.sub_part.available_stock
|
|
|
|
n = int(1.0 * stock / item.quantity)
|
|
|
|
|
|
|
|
if total is None or n < total:
|
|
|
|
total = n
|
|
|
|
|
2018-04-17 12:26:57 +00:00
|
|
|
return max(total, 0)
|
2018-04-15 14:30:57 +00:00
|
|
|
|
2018-04-17 10:25:43 +00:00
|
|
|
@property
|
|
|
|
def active_builds(self):
|
|
|
|
""" Return a list of outstanding builds.
|
|
|
|
Builds marked as 'complete' or 'cancelled' are ignored
|
|
|
|
"""
|
|
|
|
|
2019-06-04 13:38:52 +00:00
|
|
|
return self.builds.filter(status__in=BuildStatus.ACTIVE_CODES)
|
2018-04-17 10:25:43 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def inactive_builds(self):
|
|
|
|
""" Return a list of inactive builds
|
|
|
|
"""
|
|
|
|
|
2019-06-04 13:38:52 +00:00
|
|
|
return self.builds.exclude(status__in=BuildStatus.ACTIVE_CODES)
|
2018-04-17 10:25:43 +00:00
|
|
|
|
|
|
|
@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])
|
|
|
|
|
2018-04-17 12:26:57 +00:00
|
|
|
@property
|
2018-04-30 12:45:11 +00:00
|
|
|
def build_allocation(self):
|
2018-04-17 12:26:57 +00:00
|
|
|
""" Return list of builds to which this part is allocated
|
|
|
|
"""
|
|
|
|
|
|
|
|
builds = []
|
|
|
|
|
2019-05-20 21:57:44 +00:00
|
|
|
for item in self.used_in.all().prefetch_related('part__builds'):
|
2018-04-30 12:45:11 +00:00
|
|
|
|
2019-05-20 21:57:44 +00:00
|
|
|
active = item.part.active_builds
|
|
|
|
|
|
|
|
for build in active:
|
2018-04-30 12:45:11 +00:00
|
|
|
b = {}
|
|
|
|
|
|
|
|
b['build'] = build
|
|
|
|
b['quantity'] = item.quantity * build.quantity
|
|
|
|
|
|
|
|
builds.append(b)
|
2018-04-17 12:26:57 +00:00
|
|
|
|
2019-05-20 21:57:44 +00:00
|
|
|
prefetch_related_objects(builds, 'build_items')
|
|
|
|
|
2018-04-17 12:26:57 +00:00
|
|
|
return builds
|
|
|
|
|
|
|
|
@property
|
|
|
|
def allocated_build_count(self):
|
2019-05-20 12:53:01 +00:00
|
|
|
""" Return the total number of this part that are allocated for builds
|
2018-04-17 12:26:57 +00:00
|
|
|
"""
|
|
|
|
|
2018-04-30 12:45:11 +00:00
|
|
|
return sum([a['quantity'] for a in self.build_allocation])
|
2018-04-17 12:26:57 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def allocation_count(self):
|
2019-04-27 12:18:07 +00:00
|
|
|
""" Return true if any of this part is allocated:
|
|
|
|
|
2018-04-17 12:26:57 +00:00
|
|
|
- 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):
|
2019-05-20 11:47:30 +00:00
|
|
|
""" Return all 'in stock' items. To be in stock:
|
|
|
|
|
|
|
|
- customer is None
|
|
|
|
- belongs_to is None
|
|
|
|
"""
|
|
|
|
|
|
|
|
return self.stock_items.filter(customer=None, belongs_to=None)
|
2018-04-30 12:30:15 +00:00
|
|
|
|
2017-03-28 10:24:00 +00:00
|
|
|
@property
|
2018-04-16 12:13:31 +00:00
|
|
|
def total_stock(self):
|
2017-03-28 10:24:00 +00:00
|
|
|
""" Return the total stock quantity for this part.
|
|
|
|
Part may be stored in multiple locations
|
|
|
|
"""
|
2017-03-29 12:19:53 +00:00
|
|
|
|
2019-05-25 14:01:01 +00:00
|
|
|
if self.is_template:
|
|
|
|
total = sum([variant.total_stock for variant in self.variants.all()])
|
|
|
|
else:
|
2019-06-04 13:38:52 +00:00
|
|
|
total = self.stock_entries.filter(status__in=StockStatus.AVAILABLE_CODES).aggregate(total=Sum('quantity'))['total']
|
2019-05-20 11:47:30 +00:00
|
|
|
|
|
|
|
if total:
|
|
|
|
return total
|
|
|
|
else:
|
|
|
|
return 0
|
2017-03-29 12:19:53 +00:00
|
|
|
|
2018-04-13 12:36:59 +00:00
|
|
|
@property
|
2018-04-16 11:49:38 +00:00
|
|
|
def has_bom(self):
|
2018-04-16 12:13:31 +00:00
|
|
|
return self.bom_count > 0
|
2018-04-16 11:49:38 +00:00
|
|
|
|
|
|
|
@property
|
2018-04-16 12:13:31 +00:00
|
|
|
def bom_count(self):
|
2019-05-12 06:27:50 +00:00
|
|
|
""" Return the number of items contained in the BOM for this part """
|
2018-04-16 13:26:02 +00:00
|
|
|
return self.bom_items.count()
|
2018-04-13 12:36:59 +00:00
|
|
|
|
|
|
|
@property
|
2018-04-16 11:49:38 +00:00
|
|
|
def used_in_count(self):
|
2019-05-12 06:27:50 +00:00
|
|
|
""" Return the number of part BOMs that this part appears in """
|
2018-04-16 13:26:02 +00:00
|
|
|
return self.used_in.count()
|
2018-04-13 12:36:59 +00:00
|
|
|
|
2019-05-12 02:42:06 +00:00
|
|
|
def get_bom_hash(self):
|
2019-05-12 06:27:50 +00:00
|
|
|
""" Return a checksum hash for the BOM for this part.
|
2019-05-12 02:42:06 +00:00
|
|
|
Used to determine if the BOM has changed (and needs to be signed off!)
|
|
|
|
|
2019-09-05 03:10:26 +00:00
|
|
|
The hash is calculated by hashing each line item in the BOM.
|
2019-05-12 02:42:06 +00:00
|
|
|
|
|
|
|
returns a string representation of a hash object which can be compared with a stored value
|
|
|
|
"""
|
|
|
|
|
2019-05-20 12:53:01 +00:00
|
|
|
hash = hashlib.md5(str(self.id).encode())
|
2019-05-12 02:42:06 +00:00
|
|
|
|
2019-05-20 14:16:00 +00:00
|
|
|
for item in self.bom_items.all().prefetch_related('sub_part'):
|
2019-09-05 03:10:26 +00:00
|
|
|
hash.update(str(item.get_item_hash()).encode())
|
2019-05-12 02:42:06 +00:00
|
|
|
|
|
|
|
return str(hash.digest())
|
|
|
|
|
2019-05-12 02:53:56 +00:00
|
|
|
@property
|
|
|
|
def is_bom_valid(self):
|
|
|
|
""" Check if the BOM is 'valid' - if the calculated checksum matches the stored value
|
|
|
|
"""
|
|
|
|
|
|
|
|
return self.get_bom_hash() == self.bom_checksum
|
|
|
|
|
|
|
|
@transaction.atomic
|
2019-05-12 03:01:41 +00:00
|
|
|
def validate_bom(self, user):
|
2019-05-12 03:12:04 +00:00
|
|
|
""" Validate the BOM (mark the BOM as validated by the given User.
|
2019-05-12 02:53:56 +00:00
|
|
|
|
|
|
|
- Calculates and stores the hash for the BOM
|
|
|
|
- Saves the current date and the checking user
|
|
|
|
"""
|
|
|
|
|
2019-09-05 03:10:26 +00:00
|
|
|
# Validate each line item too
|
|
|
|
for item in self.bom_items.all():
|
|
|
|
item.validate_hash()
|
|
|
|
|
2019-05-12 06:27:50 +00:00
|
|
|
self.bom_checksum = self.get_bom_hash()
|
2019-05-12 02:53:56 +00:00
|
|
|
self.bom_checked_by = user
|
|
|
|
self.bom_checked_date = datetime.now().date()
|
|
|
|
|
|
|
|
self.save()
|
|
|
|
|
2019-07-10 02:27:19 +00:00
|
|
|
@transaction.atomic
|
|
|
|
def clear_bom(self):
|
|
|
|
""" Clear the BOM items for the part (delete all BOM lines).
|
|
|
|
"""
|
|
|
|
|
|
|
|
self.bom_items.all().delete()
|
|
|
|
|
2019-05-02 08:53:03 +00:00
|
|
|
def required_parts(self):
|
2019-05-12 02:53:56 +00:00
|
|
|
""" Return a list of parts required to make this part (list of BOM items) """
|
2019-05-02 08:53:03 +00:00
|
|
|
parts = []
|
2019-05-20 14:06:57 +00:00
|
|
|
for bom in self.bom_items.all().select_related('sub_part'):
|
2019-05-02 08:53:03 +00:00
|
|
|
parts.append(bom.sub_part)
|
|
|
|
return parts
|
|
|
|
|
2019-07-07 01:22:01 +00:00
|
|
|
def get_allowed_bom_items(self):
|
|
|
|
""" Return a list of parts which can be added to a BOM for this part.
|
|
|
|
|
|
|
|
- Exclude parts which are not 'component' parts
|
|
|
|
- Exclude parts which this part is in the BOM for
|
|
|
|
"""
|
|
|
|
|
2019-07-07 01:56:44 +00:00
|
|
|
parts = Part.objects.filter(component=True).exclude(id=self.id)
|
2019-07-07 01:22:01 +00:00
|
|
|
parts = parts.exclude(id__in=[part.id for part in self.used_in.all()])
|
|
|
|
|
|
|
|
return parts
|
|
|
|
|
2018-04-16 12:13:31 +00:00
|
|
|
@property
|
|
|
|
def supplier_count(self):
|
2019-05-12 02:53:56 +00:00
|
|
|
""" Return the number of supplier parts available for this part """
|
2018-04-16 13:26:02 +00:00
|
|
|
return self.supplier_parts.count()
|
2018-04-16 12:13:31 +00:00
|
|
|
|
2019-05-18 11:22:56 +00:00
|
|
|
@property
|
|
|
|
def has_pricing_info(self):
|
|
|
|
""" Return true if there is pricing information for this part """
|
2019-05-20 13:53:39 +00:00
|
|
|
return self.get_price_range() is not None
|
2019-05-18 11:22:56 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def has_complete_bom_pricing(self):
|
|
|
|
""" Return true if there is pricing information for each item in the BOM. """
|
|
|
|
|
2019-05-20 14:06:57 +00:00
|
|
|
for item in self.bom_items.all().select_related('sub_part'):
|
2019-05-18 11:22:56 +00:00
|
|
|
if not item.sub_part.has_pricing_info:
|
|
|
|
return False
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
2019-05-18 11:56:00 +00:00
|
|
|
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)
|
|
|
|
"""
|
2019-05-18 11:22:56 +00:00
|
|
|
|
2019-05-20 13:53:39 +00:00
|
|
|
price_range = self.get_price_range(quantity, buy, bom)
|
|
|
|
|
|
|
|
if price_range is None:
|
2019-05-18 11:22:56 +00:00
|
|
|
return None
|
|
|
|
|
2019-05-20 13:53:39 +00:00
|
|
|
min_price, max_price = price_range
|
|
|
|
|
2019-05-18 11:22:56 +00:00
|
|
|
if min_price == max_price:
|
|
|
|
return min_price
|
|
|
|
|
2019-05-20 14:06:57 +00:00
|
|
|
return "{a} - {b}".format(a=min_price, b=max_price)
|
2019-05-18 11:22:56 +00:00
|
|
|
|
2019-05-20 13:53:39 +00:00
|
|
|
def get_supplier_price_range(self, quantity=1):
|
2019-05-18 10:09:41 +00:00
|
|
|
|
|
|
|
min_price = None
|
2019-05-20 13:53:39 +00:00
|
|
|
max_price = None
|
2019-05-18 10:09:41 +00:00
|
|
|
|
2019-05-20 13:53:39 +00:00
|
|
|
for supplier in self.supplier_parts.all():
|
2019-05-18 10:09:41 +00:00
|
|
|
|
2019-05-20 13:53:39 +00:00
|
|
|
price = supplier.get_price(quantity)
|
2019-05-18 10:09:41 +00:00
|
|
|
|
2019-05-20 13:53:39 +00:00
|
|
|
if price is None:
|
|
|
|
continue
|
2019-05-18 10:09:41 +00:00
|
|
|
|
2019-05-20 13:53:39 +00:00
|
|
|
if min_price is None or price < min_price:
|
|
|
|
min_price = price
|
2019-05-18 10:09:41 +00:00
|
|
|
|
2019-05-20 13:53:39 +00:00
|
|
|
if max_price is None or price > max_price:
|
|
|
|
max_price = price
|
2019-05-18 10:09:41 +00:00
|
|
|
|
2019-05-20 13:53:39 +00:00
|
|
|
if min_price is None or max_price is None:
|
|
|
|
return None
|
2019-05-18 10:09:41 +00:00
|
|
|
|
2019-05-20 13:53:39 +00:00
|
|
|
return (min_price, max_price)
|
2019-05-18 10:09:41 +00:00
|
|
|
|
2019-05-20 13:53:39 +00:00
|
|
|
def get_bom_price_range(self, quantity=1):
|
|
|
|
""" Return the price range of the BOM for this part.
|
2019-05-18 11:22:56 +00:00
|
|
|
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
|
2019-05-20 13:53:39 +00:00
|
|
|
max_price = None
|
2019-05-18 11:22:56 +00:00
|
|
|
|
2019-05-20 14:06:57 +00:00
|
|
|
for item in self.bom_items.all().select_related('sub_part'):
|
2019-05-20 13:53:39 +00:00
|
|
|
prices = item.sub_part.get_price_range(quantity * item.quantity)
|
2019-05-18 11:22:56 +00:00
|
|
|
|
2019-05-20 13:53:39 +00:00
|
|
|
if prices is None:
|
2019-05-18 11:22:56 +00:00
|
|
|
continue
|
|
|
|
|
2019-05-20 13:53:39 +00:00
|
|
|
low, high = prices
|
|
|
|
|
2019-05-18 11:22:56 +00:00
|
|
|
if min_price is None:
|
|
|
|
min_price = 0
|
|
|
|
|
|
|
|
if max_price is None:
|
|
|
|
max_price = 0
|
|
|
|
|
2019-05-20 13:53:39 +00:00
|
|
|
min_price += low
|
|
|
|
max_price += high
|
|
|
|
|
|
|
|
if min_price is None or max_price is None:
|
|
|
|
return None
|
2019-05-18 11:22:56 +00:00
|
|
|
|
2019-05-20 13:53:39 +00:00
|
|
|
return (min_price, max_price)
|
2019-05-18 10:09:41 +00:00
|
|
|
|
2019-05-20 13:53:39 +00:00
|
|
|
def get_price_range(self, quantity=1, buy=True, bom=True):
|
|
|
|
|
|
|
|
""" Return the price range for this part. This price can be either:
|
2019-05-18 11:22:56 +00:00
|
|
|
|
|
|
|
- Supplier price (if purchased from suppliers)
|
|
|
|
- BOM price (if built from other parts)
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
Minimum of the supplier price or BOM price. If no pricing available, returns None
|
|
|
|
"""
|
|
|
|
|
2019-05-20 13:53:39 +00:00
|
|
|
buy_price_range = self.get_supplier_price_range(quantity) if buy else None
|
|
|
|
bom_price_range = self.get_bom_price_range(quantity) if bom else None
|
2019-05-18 11:22:56 +00:00
|
|
|
|
2019-05-20 13:53:39 +00:00
|
|
|
if buy_price_range is None:
|
|
|
|
return bom_price_range
|
2019-05-18 11:22:56 +00:00
|
|
|
|
2019-05-20 13:53:39 +00:00
|
|
|
elif bom_price_range is None:
|
|
|
|
return buy_price_range
|
2019-05-18 11:22:56 +00:00
|
|
|
|
2019-05-20 13:53:39 +00:00
|
|
|
else:
|
|
|
|
return (
|
|
|
|
min(buy_price_range[0], bom_price_range[0]),
|
|
|
|
max(buy_price_range[1], bom_price_range[1])
|
2019-05-20 14:54:48 +00:00
|
|
|
)
|
2019-05-18 11:22:56 +00:00
|
|
|
|
2019-05-13 11:54:52 +00:00
|
|
|
def deepCopy(self, other, **kwargs):
|
|
|
|
""" Duplicates non-field data from another part.
|
|
|
|
Does not alter the normal fields of this part,
|
|
|
|
but can be used to copy other data linked by ForeignKey refernce.
|
|
|
|
|
|
|
|
Keyword Args:
|
|
|
|
image: If True, copies Part image (default = True)
|
|
|
|
bom: If True, copies BOM data (default = False)
|
2019-05-13 11:41:32 +00:00
|
|
|
"""
|
|
|
|
|
2019-05-13 11:54:52 +00:00
|
|
|
# Copy the part image
|
|
|
|
if kwargs.get('image', True):
|
2019-05-14 23:21:31 +00:00
|
|
|
if other.image:
|
|
|
|
image_file = ContentFile(other.image.read())
|
2019-05-21 04:08:40 +00:00
|
|
|
image_file.name = rename_part_image(self, other.image.url)
|
2019-05-13 11:54:52 +00:00
|
|
|
|
2019-05-14 23:21:31 +00:00
|
|
|
self.image = image_file
|
2019-05-13 11:54:52 +00:00
|
|
|
|
|
|
|
# Copy the BOM data
|
|
|
|
if kwargs.get('bom', False):
|
|
|
|
for item in other.bom_items.all():
|
2019-08-28 11:12:16 +00:00
|
|
|
# Point the item to THIS part.
|
|
|
|
# Set the pk to None so a new entry is created.
|
2019-05-13 11:54:52 +00:00
|
|
|
item.part = self
|
|
|
|
item.pk = None
|
|
|
|
item.save()
|
|
|
|
|
2019-06-20 11:46:16 +00:00
|
|
|
# Copy the fields that aren't available in the duplicate form
|
|
|
|
self.salable = other.salable
|
|
|
|
self.assembly = other.assembly
|
|
|
|
self.component = other.component
|
|
|
|
self.purchaseable = other.purchaseable
|
|
|
|
self.trackable = other.trackable
|
|
|
|
self.virtual = other.virtual
|
|
|
|
|
2019-05-13 11:54:52 +00:00
|
|
|
self.save()
|
2019-05-13 11:41:32 +00:00
|
|
|
|
2019-04-16 04:22:27 +00:00
|
|
|
def export_bom(self, **kwargs):
|
2019-08-06 23:12:53 +00:00
|
|
|
""" Export Bill of Materials to a spreadsheet file.
|
|
|
|
Includes a row for each item in the BOM.
|
|
|
|
Also includes extra information such as supplier data.
|
|
|
|
"""
|
|
|
|
|
|
|
|
items = self.bom_items.all().order_by('id')
|
|
|
|
|
|
|
|
supplier_names = set()
|
2019-04-13 14:50:43 +00:00
|
|
|
|
2019-08-06 23:12:53 +00:00
|
|
|
headers = [
|
2019-04-17 13:52:15 +00:00
|
|
|
'Part',
|
|
|
|
'Description',
|
|
|
|
'Quantity',
|
2019-06-27 11:44:40 +00:00
|
|
|
'Overage',
|
|
|
|
'Reference',
|
2019-04-17 13:52:15 +00:00
|
|
|
'Note',
|
2019-08-06 23:12:53 +00:00
|
|
|
'',
|
|
|
|
'In Stock',
|
|
|
|
]
|
|
|
|
|
|
|
|
# Contstruct list of suppliers for each part
|
|
|
|
for item in items:
|
|
|
|
part = item.sub_part
|
|
|
|
supplier_parts = part.supplier_parts.all()
|
|
|
|
item.suppliers = {}
|
|
|
|
|
|
|
|
for sp in supplier_parts:
|
|
|
|
name = sp.supplier.name
|
|
|
|
supplier_names.add(name)
|
|
|
|
item.suppliers[name] = sp
|
2019-04-13 14:50:43 +00:00
|
|
|
|
2019-08-06 23:12:53 +00:00
|
|
|
if len(supplier_names) > 0:
|
|
|
|
headers.append('')
|
|
|
|
for name in supplier_names:
|
|
|
|
headers.append(name)
|
|
|
|
|
|
|
|
data = tablib.Dataset(headers=headers)
|
|
|
|
|
|
|
|
for it in items:
|
2019-04-13 14:50:43 +00:00
|
|
|
line = []
|
2019-04-16 04:22:27 +00:00
|
|
|
|
2019-08-06 23:12:53 +00:00
|
|
|
# Information about each BOM item
|
2019-05-12 02:16:04 +00:00
|
|
|
line.append(it.sub_part.full_name)
|
2019-04-13 14:50:43 +00:00
|
|
|
line.append(it.sub_part.description)
|
|
|
|
line.append(it.quantity)
|
2019-06-27 11:44:40 +00:00
|
|
|
line.append(it.overage)
|
|
|
|
line.append(it.reference)
|
2019-04-15 08:32:15 +00:00
|
|
|
line.append(it.note)
|
2019-04-13 14:50:43 +00:00
|
|
|
|
2019-08-06 23:12:53 +00:00
|
|
|
# Extra information about the part
|
|
|
|
line.append('')
|
|
|
|
line.append(it.sub_part.available_stock)
|
|
|
|
|
|
|
|
if len(supplier_names) > 0:
|
|
|
|
line.append('') # Blank column separates supplier info
|
|
|
|
|
|
|
|
for name in supplier_names:
|
|
|
|
sp = it.suppliers.get(name, None)
|
|
|
|
if sp:
|
|
|
|
line.append(sp.SKU)
|
|
|
|
else:
|
|
|
|
line.append('')
|
|
|
|
|
2019-04-17 13:52:15 +00:00
|
|
|
data.append(line)
|
2019-04-16 04:22:27 +00:00
|
|
|
|
2019-04-16 11:46:12 +00:00
|
|
|
file_format = kwargs.get('format', 'csv').lower()
|
2019-04-16 04:22:27 +00:00
|
|
|
|
2019-04-17 13:52:15 +00:00
|
|
|
return data.export(file_format)
|
2017-03-29 04:12:14 +00:00
|
|
|
|
2019-05-25 14:39:36 +00:00
|
|
|
@property
|
|
|
|
def attachment_count(self):
|
|
|
|
""" Count the number of attachments for this part.
|
2019-05-25 14:42:40 +00:00
|
|
|
If the part is a variant of a template part,
|
2019-05-25 14:39:36 +00:00
|
|
|
include the number of attachments for the template part.
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
n = self.attachments.count()
|
|
|
|
|
|
|
|
if self.variant_of:
|
|
|
|
n += self.variant_of.attachments.count()
|
|
|
|
|
|
|
|
return n
|
|
|
|
|
2019-06-05 11:47:22 +00:00
|
|
|
def purchase_orders(self):
|
|
|
|
""" Return a list of purchase orders which reference this part """
|
|
|
|
|
|
|
|
orders = []
|
|
|
|
|
|
|
|
for part in self.supplier_parts.all().prefetch_related('purchase_order_line_items'):
|
|
|
|
for order in part.purchase_orders():
|
|
|
|
if order not in orders:
|
|
|
|
orders.append(order)
|
|
|
|
|
|
|
|
return orders
|
|
|
|
|
2019-06-10 12:31:19 +00:00
|
|
|
def open_purchase_orders(self):
|
|
|
|
""" Return a list of open purchase orders against this part """
|
|
|
|
|
|
|
|
return [order for order in self.purchase_orders() if order.status in OrderStatus.OPEN]
|
|
|
|
|
|
|
|
def closed_purchase_orders(self):
|
|
|
|
""" Return a list of closed purchase orders against this part """
|
|
|
|
|
|
|
|
return [order for order in self.purchase_orders() if order.status not in OrderStatus.OPEN]
|
|
|
|
|
2019-06-10 13:05:14 +00:00
|
|
|
@property
|
2019-06-06 22:37:25 +00:00
|
|
|
def on_order(self):
|
|
|
|
""" Return the total number of items on order for this part. """
|
|
|
|
|
2019-06-10 11:56:50 +00:00
|
|
|
return sum([part.on_order() for part in self.supplier_parts.all().prefetch_related('purchase_order_line_items')])
|
2019-06-06 22:37:25 +00:00
|
|
|
|
2019-08-20 02:44:00 +00:00
|
|
|
def get_parameters(self):
|
|
|
|
""" Return all parameters for this part, ordered by name """
|
|
|
|
|
2019-08-20 03:03:36 +00:00
|
|
|
return self.parameters.order_by('template__name')
|
2019-08-20 02:44:00 +00:00
|
|
|
|
2018-04-15 15:02:17 +00:00
|
|
|
|
2018-04-14 08:44:56 +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'
|
|
|
|
"""
|
2019-04-17 14:14:53 +00:00
|
|
|
# Construct a path to store a file attachment
|
|
|
|
return os.path.join('part_files', str(instance.part.id), filename)
|
2018-04-14 08:44:56 +00:00
|
|
|
|
2018-04-15 15:02:17 +00:00
|
|
|
|
2018-04-14 08:44:56 +00:00
|
|
|
class PartAttachment(models.Model):
|
|
|
|
""" A PartAttachment links a file to a part
|
|
|
|
Parts can have multiple files such as datasheets, etc
|
2019-05-10 10:11:52 +00:00
|
|
|
|
|
|
|
Attributes:
|
|
|
|
part: Link to a Part object
|
|
|
|
attachment: File
|
|
|
|
comment: String descriptor for the attachment
|
2018-04-14 08:44:56 +00:00
|
|
|
"""
|
|
|
|
|
|
|
|
part = models.ForeignKey(Part, on_delete=models.CASCADE,
|
|
|
|
related_name='attachments')
|
|
|
|
|
2019-05-18 06:28:15 +00:00
|
|
|
attachment = models.FileField(upload_to=attach_file,
|
2019-05-08 13:32:57 +00:00
|
|
|
help_text='Select file to attach')
|
2018-04-14 08:44:56 +00:00
|
|
|
|
2019-05-18 06:20:48 +00:00
|
|
|
comment = models.CharField(max_length=100, help_text='File comment')
|
2019-04-30 23:40:49 +00:00
|
|
|
|
2019-04-17 14:14:53 +00:00
|
|
|
@property
|
|
|
|
def basename(self):
|
|
|
|
return os.path.basename(self.attachment.name)
|
|
|
|
|
2018-04-14 08:44:56 +00:00
|
|
|
|
2019-05-04 22:46:23 +00:00
|
|
|
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.
|
2019-05-10 10:11:52 +00:00
|
|
|
|
|
|
|
Attributes:
|
|
|
|
part: Link to a Part object
|
|
|
|
user: Link to a User object
|
2019-05-04 22:46:23 +00:00
|
|
|
"""
|
|
|
|
|
|
|
|
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='starred_users')
|
|
|
|
|
|
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='starred_parts')
|
|
|
|
|
2019-05-04 22:48:41 +00:00
|
|
|
class Meta:
|
|
|
|
unique_together = ['part', 'user']
|
|
|
|
|
2019-05-04 22:46:23 +00:00
|
|
|
|
2019-08-20 03:02:00 +00:00
|
|
|
class PartParameterTemplate(models.Model):
|
2019-08-28 09:44:02 +00:00
|
|
|
"""
|
|
|
|
A PartParameterTemplate provides a template for key:value pairs for extra
|
2019-08-20 02:31:43 +00:00
|
|
|
parameters fields/values to be added to a Part.
|
|
|
|
This allows users to arbitrarily assign data fields to a Part
|
|
|
|
beyond the built-in attributes.
|
|
|
|
|
|
|
|
Attributes:
|
|
|
|
name: The name (key) of the Parameter [string]
|
2019-08-20 03:02:00 +00:00
|
|
|
units: The units of the Parameter [string]
|
|
|
|
"""
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
s = str(self.name)
|
|
|
|
if self.units:
|
|
|
|
s += " ({units})".format(units=self.units)
|
|
|
|
return s
|
|
|
|
|
2019-08-20 03:08:06 +00:00
|
|
|
def validate_unique(self, exclude=None):
|
|
|
|
""" Ensure that PartParameterTemplates cannot be created with the same name.
|
|
|
|
This test should be case-insensitive (which the unique caveat does not cover).
|
|
|
|
"""
|
|
|
|
|
|
|
|
super().validate_unique(exclude)
|
|
|
|
|
|
|
|
try:
|
|
|
|
others = PartParameterTemplate.objects.exclude(id=self.id).filter(name__iexact=self.name)
|
|
|
|
|
|
|
|
if others.exists():
|
|
|
|
msg = _("Parameter template name must be unique")
|
|
|
|
raise ValidationError({"name": msg})
|
|
|
|
except PartParameterTemplate.DoesNotExist:
|
|
|
|
pass
|
2019-08-20 03:02:00 +00:00
|
|
|
|
2019-08-20 04:14:21 +00:00
|
|
|
@property
|
|
|
|
def instance_count(self):
|
|
|
|
""" Return the number of instances of this Parameter Template """
|
|
|
|
return self.instances.count()
|
|
|
|
|
2019-08-20 03:02:00 +00:00
|
|
|
name = models.CharField(max_length=100, help_text='Parameter Name')
|
|
|
|
|
|
|
|
units = models.CharField(max_length=25, help_text='Parameter Units', blank=True)
|
|
|
|
|
|
|
|
|
|
|
|
class PartParameter(models.Model):
|
2019-08-28 09:44:02 +00:00
|
|
|
"""
|
|
|
|
A PartParameter is a specific instance of a PartParameterTemplate. It assigns a particular parameter <key:value> pair to a part.
|
2019-08-20 03:02:00 +00:00
|
|
|
|
|
|
|
Attributes:
|
|
|
|
part: Reference to a single Part object
|
|
|
|
template: Reference to a single PartParameterTemplate object
|
2019-08-20 02:31:43 +00:00
|
|
|
data: The data (value) of the Parameter [string]
|
|
|
|
"""
|
|
|
|
|
2019-08-20 03:02:00 +00:00
|
|
|
def __str__(self):
|
2019-08-28 09:44:46 +00:00
|
|
|
# String representation of a PartParameter (used in the admin interface)
|
2019-08-20 03:02:00 +00:00
|
|
|
return "{part} : {param} = {data}{units}".format(
|
|
|
|
part=str(self.part),
|
|
|
|
param=str(self.template.name),
|
|
|
|
data=str(self.data),
|
|
|
|
units=str(self.template.units)
|
|
|
|
)
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
# Prevent multiple instances of a parameter for a single part
|
|
|
|
unique_together = ('part', 'template')
|
|
|
|
|
2019-08-20 02:31:43 +00:00
|
|
|
part = models.ForeignKey(Part, on_delete=models.CASCADE,
|
2019-08-28 09:44:02 +00:00
|
|
|
related_name='parameters', help_text='Parent Part')
|
2019-08-20 02:31:43 +00:00
|
|
|
|
2019-08-28 09:44:02 +00:00
|
|
|
template = models.ForeignKey(PartParameterTemplate, on_delete=models.CASCADE, related_name='instances', help_text='Parameter Template')
|
2019-08-20 02:31:43 +00:00
|
|
|
|
2019-08-20 03:02:00 +00:00
|
|
|
data = models.CharField(max_length=500, help_text='Parameter Value')
|
2019-08-20 02:31:43 +00:00
|
|
|
|
|
|
|
|
2018-04-14 04:19:03 +00:00
|
|
|
class BomItem(models.Model):
|
|
|
|
""" A BomItem links a part to its component items.
|
|
|
|
A part can have a BOM (bill of materials) which defines
|
2019-05-10 10:11:52 +00:00
|
|
|
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'
|
2019-06-27 11:44:40 +00:00
|
|
|
reference: BOM reference field (e.g. part designators)
|
2019-05-14 14:16:34 +00:00
|
|
|
overage: Estimated losses for a Build. Can be expressed as absolute value (e.g. '7') or a percentage (e.g. '2%')
|
2019-05-10 10:11:52 +00:00
|
|
|
note: Note field for this BOM item
|
2019-09-05 02:58:11 +00:00
|
|
|
checksum: Validation checksum for the particular BOM line item
|
2018-04-14 04:19:03 +00:00
|
|
|
"""
|
|
|
|
|
2018-04-15 11:29:24 +00:00
|
|
|
def get_absolute_url(self):
|
2019-04-26 13:32:22 +00:00
|
|
|
return reverse('bom-item-detail', kwargs={'pk': self.id})
|
2018-04-15 11:29:24 +00:00
|
|
|
|
2018-04-14 04:19:03 +00:00
|
|
|
# A link to the parent part
|
|
|
|
# Each part will get a reverse lookup field 'bom_items'
|
2018-04-17 13:39:53 +00:00
|
|
|
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='bom_items',
|
2019-05-14 13:36:22 +00:00
|
|
|
help_text='Select parent part',
|
2019-05-05 12:34:00 +00:00
|
|
|
limit_choices_to={
|
2019-06-02 09:46:30 +00:00
|
|
|
'assembly': True,
|
2019-05-05 12:35:39 +00:00
|
|
|
})
|
2018-04-14 04:19:03 +00:00
|
|
|
|
|
|
|
# A link to the child item (sub-part)
|
|
|
|
# Each part will get a reverse lookup field 'used_in'
|
2019-04-15 15:45:16 +00:00
|
|
|
sub_part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='used_in',
|
2019-05-14 13:36:22 +00:00
|
|
|
help_text='Select part to be used in BOM',
|
2019-05-05 12:34:00 +00:00
|
|
|
limit_choices_to={
|
2019-06-02 09:46:30 +00:00
|
|
|
'component': True,
|
2019-05-05 12:35:39 +00:00
|
|
|
})
|
2018-04-14 04:19:03 +00:00
|
|
|
|
|
|
|
# Quantity required
|
2019-05-14 13:36:22 +00:00
|
|
|
quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)], help_text='BOM quantity for this BOM item')
|
2018-04-14 04:19:03 +00:00
|
|
|
|
2019-05-14 14:16:34 +00:00
|
|
|
overage = models.CharField(max_length=24, blank=True, validators=[validators.validate_overage],
|
|
|
|
help_text='Estimated build wastage quantity (absolute or percentage)'
|
|
|
|
)
|
|
|
|
|
2019-06-27 11:44:40 +00:00
|
|
|
reference = models.CharField(max_length=500, blank=True, help_text='BOM item reference')
|
|
|
|
|
2019-04-14 08:26:11 +00:00
|
|
|
# Note attached to this BOM line item
|
2019-06-27 11:44:40 +00:00
|
|
|
note = models.CharField(max_length=500, blank=True, help_text='BOM item notes')
|
2019-04-14 08:26:11 +00:00
|
|
|
|
2019-09-05 02:58:11 +00:00
|
|
|
checksum = models.CharField(max_length=128, blank=True, help_text='BOM line checksum')
|
|
|
|
|
2019-09-05 03:10:26 +00:00
|
|
|
def get_item_hash(self):
|
|
|
|
""" Calculate the checksum hash of this BOM line item:
|
|
|
|
|
|
|
|
The hash is calculated from the following fields:
|
|
|
|
|
|
|
|
- Part.full_name (if the part name changes, the BOM checksum is invalidated)
|
|
|
|
- Quantity
|
|
|
|
- Reference field
|
|
|
|
- Note field
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
# Seed the hash with the ID of this BOM item
|
|
|
|
hash = hashlib.md5(str(self.id).encode())
|
|
|
|
|
|
|
|
# Update the hash based on line information
|
|
|
|
hash.update(str(self.sub_part.id).encode())
|
|
|
|
hash.update(str(self.sub_part.full_name).encode())
|
|
|
|
hash.update(str(self.quantity).encode())
|
|
|
|
hash.update(str(self.note).encode())
|
|
|
|
hash.update(str(self.reference).encode())
|
|
|
|
|
|
|
|
return str(hash.digest())
|
|
|
|
|
|
|
|
def validate_hash(self):
|
|
|
|
""" Mark this item as 'valid' (store the checksum hash) """
|
|
|
|
|
|
|
|
self.checksum = str(self.get_item_hash())
|
|
|
|
self.save()
|
|
|
|
|
|
|
|
@property
|
|
|
|
def is_line_valid(self):
|
|
|
|
""" Check if this line item has been validated by the user """
|
|
|
|
|
|
|
|
return self.get_item_hash() == self.checksum
|
|
|
|
|
2018-04-27 13:23:44 +00:00
|
|
|
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
|
|
|
|
"""
|
2018-04-27 13:23:44 +00:00
|
|
|
|
2018-04-27 13:42:23 +00:00
|
|
|
# A part cannot refer to itself in its BOM
|
2019-05-25 11:57:59 +00:00
|
|
|
try:
|
|
|
|
if self.sub_part is not None and self.part is not None:
|
|
|
|
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)))})
|
|
|
|
|
|
|
|
except Part.DoesNotExist:
|
|
|
|
# A blank Part will be caught elsewhere
|
|
|
|
pass
|
2018-04-27 13:23:44 +00:00
|
|
|
|
2018-04-14 04:19:03 +00:00
|
|
|
class Meta:
|
|
|
|
verbose_name = "BOM Item"
|
|
|
|
|
|
|
|
# Prevent duplication of parent/child rows
|
|
|
|
unique_together = ('part', 'sub_part')
|
|
|
|
|
|
|
|
def __str__(self):
|
2019-05-04 14:00:30 +00:00
|
|
|
return "{n} x {child} to make {parent}".format(
|
2019-05-12 02:16:04 +00:00
|
|
|
parent=self.part.full_name,
|
|
|
|
child=self.sub_part.full_name,
|
2018-04-14 04:19:03 +00:00
|
|
|
n=self.quantity)
|
2018-04-22 11:54:12 +00:00
|
|
|
|
2019-05-14 14:36:02 +00:00
|
|
|
def get_overage_quantity(self, quantity):
|
|
|
|
""" Calculate overage quantity
|
|
|
|
"""
|
|
|
|
|
|
|
|
# Most of the time overage string will be empty
|
|
|
|
if len(self.overage) == 0:
|
|
|
|
return 0
|
|
|
|
|
|
|
|
overage = str(self.overage).strip()
|
|
|
|
|
|
|
|
# Is the overage an integer value?
|
|
|
|
try:
|
|
|
|
ovg = int(overage)
|
|
|
|
|
|
|
|
if ovg < 0:
|
2019-05-14 21:23:55 +00:00
|
|
|
ovg = 0
|
2019-05-14 14:36:02 +00:00
|
|
|
|
|
|
|
return ovg
|
|
|
|
except ValueError:
|
|
|
|
pass
|
|
|
|
|
|
|
|
# Is the overage a percentage?
|
|
|
|
if overage.endswith('%'):
|
|
|
|
overage = overage[:-1].strip()
|
|
|
|
|
|
|
|
try:
|
|
|
|
percent = float(overage) / 100.0
|
2019-05-14 21:23:55 +00:00
|
|
|
if percent > 1:
|
|
|
|
percent = 1
|
|
|
|
if percent < 0:
|
|
|
|
percent = 0
|
2019-05-14 14:36:02 +00:00
|
|
|
|
2019-05-14 21:23:55 +00:00
|
|
|
return int(percent * quantity)
|
2019-05-14 14:36:02 +00:00
|
|
|
|
|
|
|
except ValueError:
|
|
|
|
pass
|
|
|
|
|
|
|
|
# Default = No overage
|
|
|
|
return 0
|
|
|
|
|
|
|
|
def get_required_quantity(self, build_quantity):
|
|
|
|
""" Calculate the required part quantity, based on the supplier build_quantity.
|
|
|
|
Includes overage estimate in the returned value.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
build_quantity: Number of parts to build
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
Quantity required for this build (including overage)
|
|
|
|
"""
|
|
|
|
|
|
|
|
# Base quantity requirement
|
|
|
|
base_quantity = self.quantity * build_quantity
|
|
|
|
|
|
|
|
return base_quantity + self.get_overage_quantity(base_quantity)
|
2019-05-21 05:15:54 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def price_range(self):
|
|
|
|
""" Return the price-range for this BOM item. """
|
|
|
|
|
|
|
|
prange = self.sub_part.get_price_range(self.quantity)
|
|
|
|
|
|
|
|
if prange is None:
|
|
|
|
return prange
|
|
|
|
|
|
|
|
pmin, pmax = prange
|
|
|
|
|
|
|
|
if pmin == pmax:
|
|
|
|
return str(pmin)
|
|
|
|
|
2019-05-21 05:38:22 +00:00
|
|
|
return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax)
|