Oliver Walters 2019-05-18 18:04:25 +10:00
parent 5043c354b1
commit 0cfb243eb3
21 changed files with 550 additions and 422 deletions

View File

@ -11,10 +11,10 @@ from django.contrib.auth import views as auth_views
from qr_code import urls as qr_code_urls from qr_code import urls as qr_code_urls
from company.urls import company_urls from company.urls import company_urls
from company.urls import supplier_part_urls
from company.urls import price_break_urls
from part.urls import part_urls from part.urls import part_urls
from part.urls import supplier_part_urls
from part.urls import price_break_urls
from stock.urls import stock_urls from stock.urls import stock_urls

View File

@ -2,10 +2,22 @@ from django.contrib import admin
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
from .models import Company from .models import Company
from .models import SupplierPart
from .models import SupplierPriceBreak
class CompanyAdmin(ImportExportModelAdmin): class CompanyAdmin(ImportExportModelAdmin):
list_display = ('name', 'website', 'contact') list_display = ('name', 'website', 'contact')
class SupplierPartAdmin(ImportExportModelAdmin):
list_display = ('part', 'supplier', 'SKU')
class SupplierPriceBreakAdmin(ImportExportModelAdmin):
list_display = ('part', 'quantity', 'cost')
admin.site.register(Company, CompanyAdmin) admin.site.register(Company, CompanyAdmin)
admin.site.register(SupplierPart, SupplierPartAdmin)
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)

View File

@ -12,7 +12,10 @@ from rest_framework import generics, permissions
from django.conf.urls import url from django.conf.urls import url
from .models import Company from .models import Company
from .models import SupplierPart, SupplierPriceBreak
from .serializers import CompanySerializer from .serializers import CompanySerializer
from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
class CompanyList(generics.ListCreateAPIView): class CompanyList(generics.ListCreateAPIView):

View File

@ -8,6 +8,8 @@ from __future__ import unicode_literals
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
from .models import Company from .models import Company
from .models import SupplierPart
from .models import SupplierPriceBreak
class EditCompanyForm(HelperForm): class EditCompanyForm(HelperForm):
@ -37,3 +39,37 @@ class CompanyImageForm(HelperForm):
fields = [ fields = [
'image' 'image'
] ]
class EditSupplierPartForm(HelperForm):
""" Form for editing a SupplierPart object """
class Meta:
model = SupplierPart
fields = [
'part',
'supplier',
'SKU',
'description',
'manufacturer',
'MPN',
'URL',
'note',
'base_cost',
'multiple',
'minimum',
'packaging',
'lead_time'
]
class EditPriceBreakForm(HelperForm):
""" Form for creating / editing a supplier price break """
class Meta:
model = SupplierPriceBreak
fields = [
'part',
'quantity',
'cost'
]

View File

@ -0,0 +1,52 @@
# Generated by Django 2.2 on 2019-05-18 07:59
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0032_auto_20190518_1759'),
('company', '0006_auto_20190508_2332'),
]
operations = [
migrations.CreateModel(
name='SupplierPart',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('SKU', models.CharField(help_text='Supplier stock keeping unit', max_length=100)),
('manufacturer', models.CharField(blank=True, help_text='Manufacturer', max_length=100)),
('MPN', models.CharField(blank=True, help_text='Manufacturer part number', max_length=100)),
('URL', models.URLField(blank=True, help_text='URL for external supplier part link')),
('description', models.CharField(blank=True, help_text='Supplier part description', max_length=250)),
('note', models.CharField(blank=True, help_text='Notes', max_length=100)),
('base_cost', models.DecimalField(decimal_places=3, default=0, help_text='Minimum charge (e.g. stocking fee)', max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('packaging', models.CharField(blank=True, help_text='Part packaging', max_length=50)),
('multiple', models.PositiveIntegerField(default=1, help_text='Order multiple', validators=[django.core.validators.MinValueValidator(1)])),
('minimum', models.PositiveIntegerField(default=1, help_text='Minimum order quantity (MOQ)', validators=[django.core.validators.MinValueValidator(1)])),
('lead_time', models.DurationField(blank=True, null=True)),
('part', models.ForeignKey(help_text='Select part', limit_choices_to={'purchaseable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='supplier_parts', to='part.Part')),
('supplier', models.ForeignKey(help_text='Select supplier', limit_choices_to={'is_supplier': True}, on_delete=django.db.models.deletion.CASCADE, related_name='parts', to='company.Company')),
],
options={
'db_table': 'part_supplierpart',
'unique_together': {('part', 'supplier', 'SKU')},
},
),
migrations.CreateModel(
name='SupplierPriceBreak',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1)])),
('cost', models.DecimalField(decimal_places=3, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])),
('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pricebreaks', to='company.SupplierPart')),
],
options={
'db_table': 'part_supplierpricebreak',
'unique_together': {('part', 'quantity')},
},
),
]

View File

@ -7,6 +7,8 @@ from __future__ import unicode_literals
import os import os
from django.core.validators import MinValueValidator
from django.apps import apps from django.apps import apps
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@ -150,3 +152,174 @@ class Contact(models.Model):
company = models.ForeignKey(Company, related_name='contacts', company = models.ForeignKey(Company, related_name='contacts',
on_delete=models.CASCADE) 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: Manufacturer name
MPN: Manufacture part number
URL: 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
minimum: MOQ (minimum order quantity) required for purchase
lead_time: Supplier lead time
packaging: packaging that the part is supplied in, e.g. "Reel"
"""
def get_absolute_url(self):
return reverse('supplier-part-detail', kwargs={'pk': self.id})
class Meta:
unique_together = ('part', 'supplier', 'SKU')
# 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',
limit_choices_to={'purchaseable': True},
help_text='Select part',
)
supplier = models.ForeignKey(Company, on_delete=models.CASCADE,
related_name='parts',
limit_choices_to={'is_supplier': True},
help_text='Select supplier',
)
SKU = models.CharField(max_length=100, help_text='Supplier stock keeping unit')
manufacturer = models.CharField(max_length=100, blank=True, help_text='Manufacturer')
MPN = models.CharField(max_length=100, blank=True, help_text='Manufacturer part number')
URL = models.URLField(blank=True, help_text='URL for external supplier part link')
description = models.CharField(max_length=250, blank=True, help_text='Supplier part description')
note = models.CharField(max_length=100, blank=True, 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, help_text='Part packaging')
multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text='Order multiple')
minimum = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text='Minimum order quantity (MOQ)')
lead_time = models.DurationField(blank=True, null=True)
@property
def manufacturer_string(self):
items = []
if self.manufacturer:
items.append(self.manufacturer)
if self.MPN:
items.append(self.MPN)
return ' | '.join(items)
@property
def has_price_breaks(self):
return self.price_breaks.count() > 0
@property
def price_breaks(self):
""" Return the associated price breaks in the correct order """
return self.pricebreaks.order_by('quantity').all()
def get_price(self, quantity, moq=True, multiples=True):
""" 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.all()
# No price break information available?
if len(price_breaks) == 0:
return None
# Minimum ordering requirement
if moq and self.minimum > quantity:
quantity = self.minimum
# Order multiples
if multiples:
quantity = int(math.ceil(quantity / self.multipe) * self.multiple)
pb_found = False
pb_quantity = -1
pb_cost = 0.0
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
pb_cost = pb.cost
if pb_found:
cost = pb_cost * quantity
return cost + self.base_cost
else:
return None
def __str__(self):
s = "{supplier} ({sku})".format(
sku=self.SKU,
supplier=self.supplier.name)
if self.manufacturer_string:
s = s + ' - ' + self.manufacturer_string
return s
class SupplierPriceBreak(models.Model):
""" Represents a quantity price break for a SupplierPart.
- Suppliers can offer discounts at larger quantities
- SupplierPart(s) may have zero-or-more associated SupplierPriceBreak(s)
Attributes:
part: Link to a SupplierPart object that this price break applies to
quantity: Quantity required for price break
cost: Cost at specified quantity
"""
part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='pricebreaks')
quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)])
cost = models.DecimalField(max_digits=10, decimal_places=3, validators=[MinValueValidator(0)])
class Meta:
unique_together = ("part", "quantity")
# This model was moved from the 'Part' app
db_table = 'part_supplierpricebreak'
def __str__(self):
return "{mpn} - {cost} @ {quan}".format(
mpn=self.part.MPN,
cost=self.cost,
quan=self.quantity)

View File

@ -5,6 +5,9 @@ JSON serializers for Company app
from rest_framework import serializers from rest_framework import serializers
from .models import Company from .models import Company
from .models import SupplierPart, SupplierPriceBreak
from part.serializers import PartBriefSerializer
class CompanyBriefSerializer(serializers.ModelSerializer): class CompanyBriefSerializer(serializers.ModelSerializer):
@ -47,3 +50,43 @@ class CompanySerializer(serializers.ModelSerializer):
'is_supplier', 'is_supplier',
'part_count' 'part_count'
] ]
class SupplierPartSerializer(serializers.ModelSerializer):
""" Serializer for SupplierPart object """
url = serializers.CharField(source='get_absolute_url', read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
supplier_name = serializers.CharField(source='supplier.name', read_only=True)
supplier_logo = serializers.CharField(source='supplier.get_image_url', read_only=True)
class Meta:
model = SupplierPart
fields = [
'pk',
'url',
'part',
'part_detail',
'supplier',
'supplier_name',
'supplier_logo',
'SKU',
'manufacturer',
'MPN',
'URL',
]
class SupplierPriceBreakSerializer(serializers.ModelSerializer):
""" Serializer for SupplierPriceBreak object """
class Meta:
model = SupplierPriceBreak
fields = [
'pk',
'part',
'quantity',
'cost'
]

View File

@ -36,3 +36,23 @@ company_urls = [
# Redirect any other patterns # Redirect any other patterns
url(r'^.*$', RedirectView.as_view(url='', permanent=False), name='company-index'), url(r'^.*$', RedirectView.as_view(url='', permanent=False), name='company-index'),
] ]
price_break_urls = [
url('^new/', views.PriceBreakCreate.as_view(), name='price-break-create'),
url(r'^(?P<pk>\d+)/edit/', views.PriceBreakEdit.as_view(), name='price-break-edit'),
url(r'^(?P<pk>\d+)/delete/', views.PriceBreakDelete.as_view(), name='price-break-delete'),
]
supplier_part_detail_urls = [
url(r'edit/?', views.SupplierPartEdit.as_view(), name='supplier-part-edit'),
url(r'delete/?', views.SupplierPartDelete.as_view(), name='supplier-part-delete'),
url('^.*$', views.SupplierPartDetail.as_view(), name='supplier-part-detail'),
]
supplier_part_urls = [
url(r'^new/?', views.SupplierPartCreate.as_view(), name='supplier-part-create'),
url(r'^(?P<pk>\d+)/', include(supplier_part_detail_urls)),
]

View File

@ -8,12 +8,18 @@ from __future__ import unicode_literals
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.forms import HiddenInput
from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from .models import Company from .models import Company
from .models import SupplierPart
from .models import SupplierPriceBreak
from .forms import EditCompanyForm from .forms import EditCompanyForm
from .forms import CompanyImageForm from .forms import CompanyImageForm
from .forms import EditSupplierPartForm
from .forms import EditPriceBreakForm
class CompanyIndex(ListView): class CompanyIndex(ListView):
@ -104,3 +110,142 @@ class CompanyDelete(AjaxDeleteView):
return { return {
'danger': 'Company was deleted', 'danger': 'Company was deleted',
} }
class SupplierPartDetail(DetailView):
""" Detail view for SupplierPart """
model = SupplierPart
template_name = 'company/partdetail.html'
context_object_name = 'part'
queryset = SupplierPart.objects.all()
class SupplierPartEdit(AjaxUpdateView):
""" Update view for editing SupplierPart """
model = SupplierPart
context_object_name = 'part'
form_class = EditSupplierPartForm
ajax_template_name = 'modal_form.html'
ajax_form_title = 'Edit Supplier Part'
class SupplierPartCreate(AjaxCreateView):
""" Create view for making new SupplierPart """
model = SupplierPart
form_class = EditSupplierPartForm
ajax_template_name = 'modal_form.html'
ajax_form_title = 'Create new Supplier Part'
context_object_name = 'part'
def get_form(self):
""" Create Form instance to create a new SupplierPart object.
Hide some fields if they are not appropriate in context
"""
form = super(AjaxCreateView, self).get_form()
if form.initial.get('supplier', None):
# Hide the supplier field
form.fields['supplier'].widget = HiddenInput()
if form.initial.get('part', None):
# Hide the part field
form.fields['part'].widget = HiddenInput()
return form
def get_initial(self):
""" Provide initial data for new SupplierPart:
- If 'supplier_id' provided, pre-fill supplier field
- If 'part_id' provided, pre-fill part field
"""
initials = super(SupplierPartCreate, self).get_initial().copy()
supplier_id = self.get_param('supplier')
part_id = self.get_param('part')
if supplier_id:
try:
initials['supplier'] = Company.objects.get(pk=supplier_id)
except Company.DoesNotExist:
initials['supplier'] = None
if part_id:
try:
initials['part'] = Part.objects.get(pk=part_id)
except Part.DoesNotExist:
initials['part'] = None
return initials
class SupplierPartDelete(AjaxDeleteView):
""" Delete view for removing a SupplierPart """
model = SupplierPart
success_url = '/supplier/'
ajax_template_name = 'company/partdelete.html'
ajax_form_title = 'Delete Supplier Part'
context_object_name = 'supplier_part'
class PriceBreakCreate(AjaxCreateView):
""" View for creating a supplier price break """
model = SupplierPriceBreak
form_class = EditPriceBreakForm
ajax_form_title = 'Add Price Break'
ajax_template_name = 'modal_form.html'
def get_data(self):
return {
'success': 'Added new price break'
}
def get_part(self):
try:
return SupplierPart.objects.get(id=self.request.GET.get('part'))
except SupplierPart.DoesNotExist:
return SupplierPart.objects.get(id=self.request.POST.get('part'))
def get_form(self):
form = super(AjaxCreateView, self).get_form()
form.fields['part'].widget = HiddenInput()
return form
def get_initial(self):
initials = super(AjaxCreateView, self).get_initial()
print("GETTING INITIAL DAtA")
initials['part'] = self.get_part()
return initials
class PriceBreakEdit(AjaxUpdateView):
""" View for editing a supplier price break """
model = SupplierPriceBreak
form_class = EditPriceBreakForm
ajax_form_title = 'Edit Price Break'
ajax_template_name = 'modal_form.html'
def get_form(self):
form = super(AjaxUpdateView, self).get_form()
form.fields['part'].widget = HiddenInput()
return form
class PriceBreakDelete(AjaxDeleteView):
""" View for deleting a supplier price break """
model = SupplierPriceBreak
ajax_form_title = "Delete Price Break"
ajax_template_name = 'modal_delete_form.html'

View File

@ -5,6 +5,5 @@ It includes models for:
- PartCategory - PartCategory
- Part - Part
- SupplierPart
- BomItem - BomItem
""" """

View File

@ -3,8 +3,6 @@ from import_export.admin import ImportExportModelAdmin
from .models import PartCategory, Part from .models import PartCategory, Part
from .models import PartAttachment, PartStar from .models import PartAttachment, PartStar
from .models import SupplierPart
from .models import SupplierPriceBreak
from .models import BomItem from .models import BomItem
@ -32,14 +30,6 @@ class BomItemAdmin(ImportExportModelAdmin):
list_display = ('part', 'sub_part', 'quantity') list_display = ('part', 'sub_part', 'quantity')
class SupplierPartAdmin(ImportExportModelAdmin):
list_display = ('part', 'supplier', 'SKU')
class SupplierPriceBreakAdmin(ImportExportModelAdmin):
list_display = ('part', 'quantity', 'cost')
""" """
class ParameterTemplateAdmin(admin.ModelAdmin): class ParameterTemplateAdmin(admin.ModelAdmin):
list_display = ('name', 'units', 'format') list_display = ('name', 'units', 'format')
@ -54,5 +44,4 @@ admin.site.register(PartCategory, PartCategoryAdmin)
admin.site.register(PartAttachment, PartAttachmentAdmin) admin.site.register(PartAttachment, PartAttachmentAdmin)
admin.site.register(PartStar, PartStarAdmin) admin.site.register(PartStar, PartStarAdmin)
admin.site.register(BomItem, BomItemAdmin) admin.site.register(BomItem, BomItemAdmin)
admin.site.register(SupplierPart, SupplierPartAdmin)
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)

View File

@ -15,11 +15,13 @@ from rest_framework import generics, permissions
from django.conf.urls import url, include from django.conf.urls import url, include
from django.urls import reverse from django.urls import reverse
from company.models import SupplierPart, SupplierPriceBreak
from company.serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
from .models import Part, PartCategory, BomItem, PartStar from .models import Part, PartCategory, BomItem, PartStar
from .models import SupplierPart, SupplierPriceBreak
from .serializers import PartSerializer, BomItemSerializer from .serializers import PartSerializer, BomItemSerializer
from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
from .serializers import CategorySerializer from .serializers import CategorySerializer
from .serializers import PartStarSerializer from .serializers import PartStarSerializer

View File

@ -11,8 +11,6 @@ from django import forms
from .models import Part, PartCategory, PartAttachment from .models import Part, PartCategory, PartAttachment
from .models import BomItem from .models import BomItem
from .models import SupplierPart
from .models import SupplierPriceBreak
class PartImageForm(HelperForm): class PartImageForm(HelperForm):
@ -140,37 +138,3 @@ class EditBomItemForm(HelperForm):
# Prevent editing of the part associated with this BomItem # Prevent editing of the part associated with this BomItem
widgets = {'part': forms.HiddenInput()} widgets = {'part': forms.HiddenInput()}
class EditSupplierPartForm(HelperForm):
""" Form for editing a SupplierPart object """
class Meta:
model = SupplierPart
fields = [
'part',
'supplier',
'SKU',
'description',
'manufacturer',
'MPN',
'URL',
'note',
'base_cost',
'multiple',
'minimum',
'packaging',
'lead_time'
]
class EditPriceBreakForm(HelperForm):
""" Form for creating / editing a supplier price break """
class Meta:
model = SupplierPriceBreak
fields = [
'part',
'quantity',
'cost'
]

View File

@ -0,0 +1,34 @@
# Generated by Django 2.2 on 2019-05-18 07:59
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0017_auto_20190518_1759'),
('part', '0031_auto_20190518_1650'),
]
operations = [
migrations.AlterUniqueTogether(
name='supplierpricebreak',
unique_together=None,
),
migrations.RemoveField(
model_name='supplierpricebreak',
name='part',
),
migrations.AlterField(
model_name='part',
name='default_supplier',
field=models.ForeignKey(blank=True, help_text='Default supplier part', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_parts', to='company.SupplierPart'),
),
migrations.DeleteModel(
name='SupplierPart',
),
migrations.DeleteModel(
name='SupplierPriceBreak',
),
]

View File

@ -32,7 +32,8 @@ import hashlib
from InvenTree import helpers from InvenTree import helpers
from InvenTree import validators from InvenTree import validators
from InvenTree.models import InvenTreeTree from InvenTree.models import InvenTreeTree
from company.models import Company
from company.models import SupplierPart
class PartCategory(InvenTreeTree): class PartCategory(InvenTreeTree):
@ -317,7 +318,7 @@ class Part(models.Model):
# Default to None if there are multiple suppliers to choose from # Default to None if there are multiple suppliers to choose from
return None return None
default_supplier = models.ForeignKey('part.SupplierPart', default_supplier = models.ForeignKey(SupplierPart,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
blank=True, null=True, blank=True, null=True,
help_text='Default supplier part', help_text='Default supplier part',
@ -800,167 +801,3 @@ class BomItem(models.Model):
return base_quantity + self.get_overage_quantity(base_quantity) return base_quantity + self.get_overage_quantity(base_quantity)
class SupplierPart(models.Model):
""" Represents a unique part as provided by a Supplier
Each SupplierPart is identified by a MPN (Manufacturer Part Number)
Each SupplierPart is also linked to a Part object.
A Part may be available from multiple suppliers
Attributes:
part: Link to the master Part
supplier: Company that supplies this SupplierPart object
SKU: Stock keeping unit (supplier part number)
manufacturer: Manufacturer name
MPN: Manufacture part number
URL: Link to external website for this part
description: Descriptive notes field
note: Longer form note field
base_cost: Base charge added to order independent of quantity e.g. "Reeling Fee"
multiple: Multiple that the part is provided in
minimum: MOQ (minimum order quantity) required for purchase
lead_time: Supplier lead time
packaging: packaging that the part is supplied in, e.g. "Reel"
"""
def get_absolute_url(self):
return reverse('supplier-part-detail', kwargs={'pk': self.id})
class Meta:
unique_together = ('part', 'supplier', 'SKU')
part = models.ForeignKey(Part, on_delete=models.CASCADE,
related_name='supplier_parts',
limit_choices_to={'purchaseable': True},
help_text='Select part',
)
supplier = models.ForeignKey(Company, on_delete=models.CASCADE,
related_name='parts',
limit_choices_to={'is_supplier': True},
help_text='Select supplier',
)
SKU = models.CharField(max_length=100, help_text='Supplier stock keeping unit')
manufacturer = models.CharField(max_length=100, blank=True, help_text='Manufacturer')
MPN = models.CharField(max_length=100, blank=True, help_text='Manufacturer part number')
URL = models.URLField(blank=True, help_text='URL for external supplier part link')
description = models.CharField(max_length=250, blank=True, help_text='Supplier part description')
note = models.CharField(max_length=100, blank=True, 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, help_text='Part packaging')
multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text='Order multiple')
minimum = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text='Minimum order quantity (MOQ)')
lead_time = models.DurationField(blank=True, null=True)
@property
def manufacturer_string(self):
items = []
if self.manufacturer:
items.append(self.manufacturer)
if self.MPN:
items.append(self.MPN)
return ' | '.join(items)
@property
def has_price_breaks(self):
return self.price_breaks.count() > 0
@property
def price_breaks(self):
""" Return the associated price breaks in the correct order """
return self.pricebreaks.order_by('quantity').all()
def get_price(self, quantity, moq=True, multiples=True):
""" 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.all()
# No price break information available?
if len(price_breaks) == 0:
return None
# Minimum ordering requirement
if moq and self.minimum > quantity:
quantity = self.minimum
# Order multiples
if multiples:
quantity = int(math.ceil(quantity / self.multipe) * self.multiple)
pb_found = False
pb_quantity = -1
pb_cost = 0.0
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
pb_cost = pb.cost
if pb_found:
cost = pb_cost * quantity
return cost + self.base_cost
else:
return None
def __str__(self):
s = "{supplier} ({sku})".format(
sku=self.SKU,
supplier=self.supplier.name)
if self.manufacturer_string:
s = s + ' - ' + self.manufacturer_string
return s
class SupplierPriceBreak(models.Model):
""" Represents a quantity price break for a SupplierPart.
- Suppliers can offer discounts at larger quantities
- SupplierPart(s) may have zero-or-more associated SupplierPriceBreak(s)
Attributes:
part: Link to a SupplierPart object that this price break applies to
quantity: Quantity required for price break
cost: Cost at specified quantity
"""
part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='pricebreaks')
quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)])
cost = models.DecimalField(max_digits=10, decimal_places=3, validators=[MinValueValidator(0)])
class Meta:
unique_together = ("part", "quantity")
def __str__(self):
return "{mpn} - {cost} @ {quan}".format(
mpn=self.part.MPN,
cost=self.cost,
quan=self.quantity)

View File

@ -5,7 +5,7 @@ JSON serializers for Part app
from rest_framework import serializers from rest_framework import serializers
from .models import Part, PartStar from .models import Part, PartStar
from .models import SupplierPart, SupplierPriceBreak
from .models import PartCategory from .models import PartCategory
from .models import BomItem from .models import BomItem
@ -119,43 +119,3 @@ class BomItemSerializer(InvenTreeModelSerializer):
'overage', 'overage',
'note', 'note',
] ]
class SupplierPartSerializer(serializers.ModelSerializer):
""" Serializer for SupplierPart object """
url = serializers.CharField(source='get_absolute_url', read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
supplier_name = serializers.CharField(source='supplier.name', read_only=True)
supplier_logo = serializers.CharField(source='supplier.get_image_url', read_only=True)
class Meta:
model = SupplierPart
fields = [
'pk',
'url',
'part',
'part_detail',
'supplier',
'supplier_name',
'supplier_logo',
'SKU',
'manufacturer',
'MPN',
'URL',
]
class SupplierPriceBreakSerializer(serializers.ModelSerializer):
""" Serializer for SupplierPriceBreak object """
class Meta:
model = SupplierPriceBreak
fields = [
'pk',
'part',
'quantity',
'cost'
]

View File

@ -12,26 +12,6 @@ from django.conf.urls import url, include
from . import views from . import views
price_break_urls = [
url('^new/', views.PriceBreakCreate.as_view(), name='price-break-create'),
url(r'^(?P<pk>\d+)/edit/', views.PriceBreakEdit.as_view(), name='price-break-edit'),
url(r'^(?P<pk>\d+)/delete/', views.PriceBreakDelete.as_view(), name='price-break-delete'),
]
supplier_part_detail_urls = [
url(r'edit/?', views.SupplierPartEdit.as_view(), name='supplier-part-edit'),
url(r'delete/?', views.SupplierPartDelete.as_view(), name='supplier-part-delete'),
url('^.*$', views.SupplierPartDetail.as_view(), name='supplier-part-detail'),
]
supplier_part_urls = [
url(r'^new/?', views.SupplierPartCreate.as_view(), name='supplier-part-create'),
url(r'^(?P<pk>\d+)/', include(supplier_part_detail_urls)),
]
part_attachment_urls = [ part_attachment_urls = [
url('^new/?', views.PartAttachmentCreate.as_view(), name='part-attachment-create'), url('^new/?', views.PartAttachmentCreate.as_view(), name='part-attachment-create'),
url(r'^(?P<pk>\d+)/edit/?', views.PartAttachmentEdit.as_view(), name='part-attachment-edit'), url(r'^(?P<pk>\d+)/edit/?', views.PartAttachmentEdit.as_view(), name='part-attachment-edit'),

View File

@ -15,8 +15,6 @@ from django.forms import HiddenInput, CheckboxInput
from company.models import Company from company.models import Company
from .models import PartCategory, Part, PartAttachment from .models import PartCategory, Part, PartAttachment
from .models import BomItem from .models import BomItem
from .models import SupplierPart
from .models import SupplierPriceBreak
from .models import match_part_names from .models import match_part_names
from . import forms as part_forms from . import forms as part_forms
@ -732,142 +730,3 @@ class BomItemDelete(AjaxDeleteView):
ajax_template_name = 'part/bom-delete.html' ajax_template_name = 'part/bom-delete.html'
context_object_name = 'item' context_object_name = 'item'
ajax_form_title = 'Confim BOM item deletion' ajax_form_title = 'Confim BOM item deletion'
class SupplierPartDetail(DetailView):
""" Detail view for SupplierPart """
model = SupplierPart
template_name = 'company/partdetail.html'
context_object_name = 'part'
queryset = SupplierPart.objects.all()
class SupplierPartEdit(AjaxUpdateView):
""" Update view for editing SupplierPart """
model = SupplierPart
context_object_name = 'part'
form_class = part_forms.EditSupplierPartForm
ajax_template_name = 'modal_form.html'
ajax_form_title = 'Edit Supplier Part'
class SupplierPartCreate(AjaxCreateView):
""" Create view for making new SupplierPart """
model = SupplierPart
form_class = part_forms.EditSupplierPartForm
ajax_template_name = 'modal_form.html'
ajax_form_title = 'Create new Supplier Part'
context_object_name = 'part'
def get_form(self):
""" Create Form instance to create a new SupplierPart object.
Hide some fields if they are not appropriate in context
"""
form = super(AjaxCreateView, self).get_form()
if form.initial.get('supplier', None):
# Hide the supplier field
form.fields['supplier'].widget = HiddenInput()
if form.initial.get('part', None):
# Hide the part field
form.fields['part'].widget = HiddenInput()
return form
def get_initial(self):
""" Provide initial data for new SupplierPart:
- If 'supplier_id' provided, pre-fill supplier field
- If 'part_id' provided, pre-fill part field
"""
initials = super(SupplierPartCreate, self).get_initial().copy()
supplier_id = self.get_param('supplier')
part_id = self.get_param('part')
if supplier_id:
try:
initials['supplier'] = Company.objects.get(pk=supplier_id)
except Company.DoesNotExist:
initials['supplier'] = None
if part_id:
try:
initials['part'] = Part.objects.get(pk=part_id)
except Part.DoesNotExist:
initials['part'] = None
return initials
class SupplierPartDelete(AjaxDeleteView):
""" Delete view for removing a SupplierPart """
model = SupplierPart
success_url = '/supplier/'
ajax_template_name = 'company/partdelete.html'
ajax_form_title = 'Delete Supplier Part'
context_object_name = 'supplier_part'
class PriceBreakCreate(AjaxCreateView):
""" View for creating a supplier price break """
model = SupplierPriceBreak
form_class = part_forms.EditPriceBreakForm
ajax_form_title = 'Add Price Break'
ajax_template_name = 'modal_form.html'
def get_data(self):
return {
'success': 'Added new price break'
}
def get_part(self):
try:
return SupplierPart.objects.get(id=self.request.GET.get('part'))
except SupplierPart.DoesNotExist:
return SupplierPart.objects.get(id=self.request.POST.get('part'))
def get_form(self):
form = super(AjaxCreateView, self).get_form()
form.fields['part'].widget = HiddenInput()
return form
def get_initial(self):
initials = super(AjaxCreateView, self).get_initial()
print("GETTING INITIAL DAtA")
initials['part'] = self.get_part()
return initials
class PriceBreakEdit(AjaxUpdateView):
""" View for editing a supplier price break """
model = SupplierPriceBreak
form_class = part_forms.EditPriceBreakForm
ajax_form_title = 'Edit Price Break'
ajax_template_name = 'modal_form.html'
def get_form(self):
form = super(AjaxUpdateView, self).get_form()
form.fields['part'].widget = HiddenInput()
return form
class PriceBreakDelete(AjaxDeleteView):
""" View for deleting a supplier price break """
model = SupplierPriceBreak
ajax_form_title = "Delete Price Break"
ajax_template_name = 'modal_delete_form.html'

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2 on 2019-05-18 07:59
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('stock', '0016_auto_20190512_2119'),
]
operations = [
migrations.AlterField(
model_name='stockitem',
name='supplier_part',
field=models.ForeignKey(blank=True, help_text='Select a matching supplier part for this stock item', null=True, on_delete=django.db.models.deletion.SET_NULL, to='company.SupplierPart'),
),
]

View File

@ -22,6 +22,7 @@ from InvenTree import helpers
from InvenTree.models import InvenTreeTree from InvenTree.models import InvenTreeTree
from part.models import Part from part.models import Part
from company.models import SupplierPart
class StockLocation(InvenTreeTree): class StockLocation(InvenTreeTree):
@ -188,7 +189,7 @@ class StockItem(models.Model):
part = models.ForeignKey('part.Part', on_delete=models.CASCADE, related_name='locations', help_text='Base part') part = models.ForeignKey('part.Part', on_delete=models.CASCADE, related_name='locations', help_text='Base part')
supplier_part = models.ForeignKey('part.SupplierPart', blank=True, null=True, on_delete=models.SET_NULL, supplier_part = models.ForeignKey(SupplierPart, blank=True, null=True, on_delete=models.SET_NULL,
help_text='Select a matching supplier part for this stock item') help_text='Select a matching supplier part for this stock item')
location = models.ForeignKey(StockLocation, on_delete=models.DO_NOTHING, location = models.ForeignKey(StockLocation, on_delete=models.DO_NOTHING,

BIN
inventree_db.sqlite3.backup Normal file

Binary file not shown.