mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
2a91bb0c87
- django-import-export plugin seems to require null=True for char fields - yes, this goes against django "best practice" - Hopefully a better solution can be found
543 lines
17 KiB
Python
543 lines
17 KiB
Python
"""
|
|
Company database model definitions
|
|
"""
|
|
|
|
# -*- coding: utf-8 -*-
|
|
from __future__ import unicode_literals
|
|
|
|
import os
|
|
|
|
import math
|
|
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.core.validators import MinValueValidator
|
|
from django.db import models
|
|
from django.db.models import Sum, Q, UniqueConstraint
|
|
|
|
from django.apps import apps
|
|
from django.urls import reverse
|
|
|
|
from moneyed import CURRENCIES
|
|
|
|
from markdownx.models import MarkdownxField
|
|
|
|
from stdimage.models import StdImageField
|
|
|
|
from InvenTree.helpers import getMediaUrl, getBlankImage, getBlankThumbnail
|
|
from InvenTree.helpers import normalize
|
|
from InvenTree.fields import InvenTreeURLField
|
|
from InvenTree.status_codes import PurchaseOrderStatus
|
|
|
|
import InvenTree.validators
|
|
|
|
import common.models
|
|
import common.settings
|
|
|
|
|
|
def rename_company_image(instance, filename):
|
|
""" Function to rename a company image after upload
|
|
|
|
Args:
|
|
instance: Company object
|
|
filename: uploaded image filename
|
|
|
|
Returns:
|
|
New image filename
|
|
"""
|
|
|
|
base = 'company_images'
|
|
|
|
if filename.count('.') > 0:
|
|
ext = filename.split('.')[-1]
|
|
else:
|
|
ext = ''
|
|
|
|
fn = 'company_{pk}_img'.format(pk=instance.pk)
|
|
|
|
if ext:
|
|
fn += '.' + ext
|
|
|
|
return os.path.join(base, fn)
|
|
|
|
|
|
class Company(models.Model):
|
|
""" A Company object represents an external company.
|
|
It may be a supplier or a customer or a manufacturer (or a combination)
|
|
|
|
- A supplier is a company from which parts can be purchased
|
|
- A customer is a company to which parts can be sold
|
|
- A manufacturer is a company which manufactures a raw good (they may or may not be a "supplier" also)
|
|
|
|
|
|
Attributes:
|
|
name: Brief name of the company
|
|
description: Longer form description
|
|
website: URL for the company website
|
|
address: Postal address
|
|
phone: contact phone number
|
|
email: contact email address
|
|
link: Secondary URL e.g. for link to internal Wiki page
|
|
image: Company image / logo
|
|
notes: Extra notes about the company
|
|
is_customer: boolean value, is this company a customer
|
|
is_supplier: boolean value, is this company a supplier
|
|
is_manufacturer: boolean value, is this company a manufacturer
|
|
currency_code: Specifies the default currency for the company
|
|
"""
|
|
|
|
class Meta:
|
|
ordering = ['name', ]
|
|
constraints = [
|
|
UniqueConstraint(fields=['name', 'email'], name='unique_name_email_pair')
|
|
]
|
|
|
|
name = models.CharField(max_length=100, blank=False,
|
|
help_text=_('Company name'),
|
|
verbose_name=_('Company name'))
|
|
|
|
description = models.CharField(max_length=500, verbose_name=_('Company description'), help_text=_('Description of the company'))
|
|
|
|
website = models.URLField(blank=True, verbose_name=_('Website'), help_text=_('Company website URL'))
|
|
|
|
address = models.CharField(max_length=200,
|
|
verbose_name=_('Address'),
|
|
blank=True, help_text=_('Company address'))
|
|
|
|
phone = models.CharField(max_length=50,
|
|
verbose_name=_('Phone number'),
|
|
blank=True, help_text=_('Contact phone number'))
|
|
|
|
email = models.EmailField(blank=True, null=True,
|
|
verbose_name=_('Email'), help_text=_('Contact email address'))
|
|
|
|
contact = models.CharField(max_length=100,
|
|
verbose_name=_('Contact'),
|
|
blank=True, help_text=_('Point of contact'))
|
|
|
|
link = InvenTreeURLField(blank=True, help_text=_('Link to external company information'))
|
|
|
|
image = StdImageField(
|
|
upload_to=rename_company_image,
|
|
null=True,
|
|
blank=True,
|
|
variations={'thumbnail': (128, 128)},
|
|
delete_orphans=True,
|
|
)
|
|
|
|
notes = MarkdownxField(blank=True)
|
|
|
|
is_customer = models.BooleanField(default=False, help_text=_('Do you sell items to this company?'))
|
|
|
|
is_supplier = models.BooleanField(default=True, help_text=_('Do you purchase items from this company?'))
|
|
|
|
is_manufacturer = models.BooleanField(default=False, help_text=_('Does this company manufacture parts?'))
|
|
|
|
currency = models.CharField(
|
|
max_length=3,
|
|
verbose_name=_('Currency'),
|
|
blank=True,
|
|
help_text=_('Default currency used for this company'),
|
|
validators=[InvenTree.validators.validate_currency_code],
|
|
)
|
|
|
|
@property
|
|
def currency_code(self):
|
|
"""
|
|
Return the currency code associated with this company.
|
|
|
|
- If the currency code is invalid, use the default currency
|
|
- If the currency code is not specified, use the default currency
|
|
"""
|
|
|
|
code = self.currency
|
|
|
|
if code not in CURRENCIES:
|
|
code = common.settings.currency_code_default()
|
|
|
|
return code
|
|
|
|
def __str__(self):
|
|
""" Get string representation of a Company """
|
|
return "{n} - {d}".format(n=self.name, d=self.description)
|
|
|
|
def get_absolute_url(self):
|
|
""" Get the web URL for the detail view for this Company """
|
|
return reverse('company-detail', kwargs={'pk': self.id})
|
|
|
|
def get_image_url(self):
|
|
""" Return the URL of the image for this company """
|
|
|
|
if self.image:
|
|
return getMediaUrl(self.image.url)
|
|
else:
|
|
return getBlankImage()
|
|
|
|
def get_thumbnail_url(self):
|
|
""" Return the URL for the thumbnail image for this Company """
|
|
|
|
if self.image:
|
|
return getMediaUrl(self.image.thumbnail.url)
|
|
else:
|
|
return getBlankThumbnail()
|
|
|
|
@property
|
|
def manufactured_part_count(self):
|
|
""" The number of parts manufactured by this company """
|
|
return self.manufactured_parts.count()
|
|
|
|
@property
|
|
def has_manufactured_parts(self):
|
|
return self.manufactured_part_count > 0
|
|
|
|
@property
|
|
def supplied_part_count(self):
|
|
""" The number of parts supplied by this company """
|
|
return self.supplied_parts.count()
|
|
|
|
@property
|
|
def has_supplied_parts(self):
|
|
""" Return True if this company supplies any parts """
|
|
return self.supplied_part_count > 0
|
|
|
|
@property
|
|
def parts(self):
|
|
""" Return SupplierPart objects which are supplied or manufactured by this company """
|
|
return SupplierPart.objects.filter(Q(supplier=self.id) | Q(manufacturer=self.id))
|
|
|
|
@property
|
|
def part_count(self):
|
|
""" The number of parts manufactured (or supplied) by this Company """
|
|
return self.parts.count()
|
|
|
|
@property
|
|
def has_parts(self):
|
|
return self.part_count > 0
|
|
|
|
@property
|
|
def stock_items(self):
|
|
""" Return a list of all stock items supplied or manufactured by this company """
|
|
stock = apps.get_model('stock', 'StockItem')
|
|
return stock.objects.filter(Q(supplier_part__supplier=self.id) | Q(supplier_part__manufacturer=self.id)).all()
|
|
|
|
@property
|
|
def stock_count(self):
|
|
""" Return the number of stock items supplied or manufactured by this company """
|
|
return self.stock_items.count()
|
|
|
|
def outstanding_purchase_orders(self):
|
|
""" Return purchase orders which are 'outstanding' """
|
|
return self.purchase_orders.filter(status__in=PurchaseOrderStatus.OPEN)
|
|
|
|
def pending_purchase_orders(self):
|
|
""" Return purchase orders which are PENDING (not yet issued) """
|
|
return self.purchase_orders.filter(status=PurchaseOrderStatus.PENDING)
|
|
|
|
def closed_purchase_orders(self):
|
|
""" Return purchase orders which are not 'outstanding'
|
|
|
|
- Complete
|
|
- Failed / lost
|
|
- Returned
|
|
"""
|
|
|
|
return self.purchase_orders.exclude(status__in=PurchaseOrderStatus.OPEN)
|
|
|
|
def complete_purchase_orders(self):
|
|
return self.purchase_orders.filter(status=PurchaseOrderStatus.COMPLETE)
|
|
|
|
def failed_purchase_orders(self):
|
|
""" Return any purchase orders which were not successful """
|
|
|
|
return self.purchase_orders.filter(status__in=PurchaseOrderStatus.FAILED)
|
|
|
|
|
|
class Contact(models.Model):
|
|
""" A Contact represents a person who works at a particular company.
|
|
A Company may have zero or more associated Contact objects.
|
|
|
|
Attributes:
|
|
company: Company link for this contact
|
|
name: Name of the contact
|
|
phone: contact phone number
|
|
email: contact email
|
|
role: position in company
|
|
"""
|
|
|
|
company = models.ForeignKey(Company, related_name='contacts',
|
|
on_delete=models.CASCADE)
|
|
|
|
name = models.CharField(max_length=100)
|
|
|
|
phone = models.CharField(max_length=100, blank=True)
|
|
|
|
email = models.EmailField(blank=True)
|
|
|
|
role = models.CharField(max_length=100, blank=True)
|
|
|
|
company = models.ForeignKey(Company, related_name='contacts',
|
|
on_delete=models.CASCADE)
|
|
|
|
|
|
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: Company that manufactures the SupplierPart (leave blank if it is the sample as the Supplier!)
|
|
MPN: Manufacture part number
|
|
link: Link to external website for this part
|
|
description: Descriptive notes field
|
|
note: Longer form note field
|
|
base_cost: Base charge added to order independent of quantity e.g. "Reeling Fee"
|
|
multiple: Multiple that the part is provided in
|
|
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')
|
|
|
|
# This model was moved from the 'Part' app
|
|
db_table = 'part_supplierpart'
|
|
|
|
part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
|
|
related_name='supplier_parts',
|
|
verbose_name=_('Base Part'),
|
|
limit_choices_to={
|
|
'purchaseable': True,
|
|
},
|
|
help_text=_('Select part'),
|
|
)
|
|
|
|
supplier = models.ForeignKey(Company, on_delete=models.CASCADE,
|
|
related_name='supplied_parts',
|
|
limit_choices_to={'is_supplier': True},
|
|
verbose_name=_('Supplier'),
|
|
help_text=_('Select supplier'),
|
|
)
|
|
|
|
SKU = models.CharField(
|
|
max_length=100,
|
|
verbose_name=_('SKU'),
|
|
help_text=_('Supplier stock keeping unit')
|
|
)
|
|
|
|
manufacturer = models.ForeignKey(
|
|
Company,
|
|
on_delete=models.SET_NULL,
|
|
related_name='manufactured_parts',
|
|
limit_choices_to={
|
|
'is_manufacturer': True
|
|
},
|
|
verbose_name=_('Manufacturer'),
|
|
help_text=_('Select manufacturer'),
|
|
null=True, blank=True
|
|
)
|
|
|
|
MPN = models.CharField(
|
|
max_length=100, blank=True, null=True,
|
|
verbose_name=_('MPN'),
|
|
help_text=_('Manufacturer part number')
|
|
)
|
|
|
|
link = InvenTreeURLField(
|
|
blank=True, null=True,
|
|
verbose_name=_('Link'),
|
|
help_text=_('URL for external supplier part link')
|
|
)
|
|
|
|
description = models.CharField(
|
|
max_length=250, blank=True, null=True,
|
|
verbose_name=_('Description'),
|
|
help_text=_('Supplier part description')
|
|
)
|
|
|
|
note = models.CharField(
|
|
max_length=100, blank=True, null=True,
|
|
verbose_name=_('Note'),
|
|
help_text=_('Notes')
|
|
)
|
|
|
|
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, null=True, help_text=_('Part packaging'))
|
|
|
|
multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text=('Order multiple'))
|
|
|
|
# TODO - Reimplement lead-time as a charfield with special validation (pattern matching).
|
|
# lead_time = models.DurationField(blank=True, null=True)
|
|
|
|
@property
|
|
def manufacturer_string(self):
|
|
""" Format a MPN string for this SupplierPart.
|
|
Concatenates manufacture name and part number.
|
|
"""
|
|
|
|
items = []
|
|
|
|
if self.manufacturer:
|
|
items.append(self.manufacturer.name)
|
|
if self.MPN:
|
|
items.append(self.MPN)
|
|
|
|
return ' | '.join(items)
|
|
|
|
@property
|
|
def has_price_breaks(self):
|
|
return self.price_breaks.count() > 0
|
|
|
|
@property
|
|
def price_breaks(self):
|
|
""" Return the associated price breaks in the correct order """
|
|
return self.pricebreaks.order_by('quantity').all()
|
|
|
|
@property
|
|
def unit_pricing(self):
|
|
return self.get_price(1)
|
|
|
|
def add_price_break(self, quantity, price):
|
|
"""
|
|
Create a new price break for this part
|
|
|
|
args:
|
|
quantity - Numerical quantity
|
|
price - Must be a Money object
|
|
"""
|
|
|
|
# Check if a price break at that quantity already exists...
|
|
if self.price_breaks.filter(quantity=quantity, part=self.pk).exists():
|
|
return
|
|
|
|
SupplierPriceBreak.objects.create(
|
|
part=self,
|
|
quantity=quantity,
|
|
price=price
|
|
)
|
|
|
|
def get_price(self, quantity, moq=True, multiples=True, currency=None):
|
|
""" Calculate the supplier price based on quantity price breaks.
|
|
|
|
- Don't forget to add in flat-fee cost (base_cost field)
|
|
- If MOQ (minimum order quantity) is required, bump quantity
|
|
- If order multiples are to be observed, then we need to calculate based on that, too
|
|
"""
|
|
|
|
price_breaks = self.price_breaks.filter(quantity__lte=quantity)
|
|
|
|
# No price break information available?
|
|
if len(price_breaks) == 0:
|
|
return None
|
|
|
|
# Order multiples
|
|
if multiples:
|
|
quantity = int(math.ceil(quantity / self.multiple) * self.multiple)
|
|
|
|
pb_found = False
|
|
pb_quantity = -1
|
|
pb_cost = 0.0
|
|
|
|
if currency is None:
|
|
# Default currency selection
|
|
currency = common.models.InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
|
|
|
|
for pb in self.price_breaks.all():
|
|
# Ignore this pricebreak (quantity is too high)
|
|
if pb.quantity > quantity:
|
|
continue
|
|
|
|
pb_found = True
|
|
|
|
# If this price-break quantity is the largest so far, use it!
|
|
if pb.quantity > pb_quantity:
|
|
pb_quantity = pb.quantity
|
|
|
|
# Convert everything to the selected currency
|
|
pb_cost = pb.convert_to(currency)
|
|
|
|
if pb_found:
|
|
cost = pb_cost * quantity
|
|
return normalize(cost + self.base_cost)
|
|
else:
|
|
return None
|
|
|
|
def open_orders(self):
|
|
""" Return a database query for PO line items for this SupplierPart,
|
|
limited to purchase orders that are open / outstanding.
|
|
"""
|
|
|
|
return self.purchase_order_line_items.prefetch_related('order').filter(order__status__in=PurchaseOrderStatus.OPEN)
|
|
|
|
def on_order(self):
|
|
""" Return the total quantity of items currently on order.
|
|
|
|
Subtract partially received stock as appropriate
|
|
"""
|
|
|
|
totals = self.open_orders().aggregate(Sum('quantity'), Sum('received'))
|
|
|
|
# Quantity on order
|
|
q = totals.get('quantity__sum', 0)
|
|
|
|
# Quantity received
|
|
r = totals.get('received__sum', 0)
|
|
|
|
if q is None or r is None:
|
|
return 0
|
|
else:
|
|
return max(q - r, 0)
|
|
|
|
def purchase_orders(self):
|
|
""" Returns a list of purchase orders relating to this supplier part """
|
|
|
|
return [line.order for line in self.purchase_order_line_items.all().prefetch_related('order')]
|
|
|
|
@property
|
|
def pretty_name(self):
|
|
return str(self)
|
|
|
|
def __str__(self):
|
|
s = ''
|
|
|
|
if self.part.IPN:
|
|
s += f'{self.part.IPN}'
|
|
s += ' | '
|
|
|
|
s += f'{self.supplier.name} | {self.SKU}'
|
|
|
|
if self.manufacturer_string:
|
|
s = s + ' | ' + self.manufacturer_string
|
|
|
|
return s
|
|
|
|
|
|
class SupplierPriceBreak(common.models.PriceBreak):
|
|
""" 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
|
|
currency: Reference to the currency of this pricebreak (leave empty for base currency)
|
|
"""
|
|
|
|
part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='pricebreaks')
|
|
|
|
class Meta:
|
|
unique_together = ("part", "quantity")
|
|
|
|
# This model was moved from the 'Part' app
|
|
db_table = 'part_supplierpricebreak'
|
|
|
|
def __str__(self):
|
|
return f'{self.part.MPN} - {self.price} @ {self.quantity}'
|