diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index ee03848ae6..1bffd90229 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -11,10 +11,10 @@ from django.contrib.auth import views as auth_views from qr_code import urls as qr_code_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 supplier_part_urls -from part.urls import price_break_urls from stock.urls import stock_urls diff --git a/InvenTree/company/admin.py b/InvenTree/company/admin.py index cbeff65cf8..25dbd1c5b6 100644 --- a/InvenTree/company/admin.py +++ b/InvenTree/company/admin.py @@ -2,10 +2,22 @@ from django.contrib import admin from import_export.admin import ImportExportModelAdmin from .models import Company +from .models import SupplierPart +from .models import SupplierPriceBreak class CompanyAdmin(ImportExportModelAdmin): 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(SupplierPart, SupplierPartAdmin) +admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin) \ No newline at end of file diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py index dfa3ebfb77..bf2f417f3b 100644 --- a/InvenTree/company/api.py +++ b/InvenTree/company/api.py @@ -12,7 +12,10 @@ from rest_framework import generics, permissions from django.conf.urls import url from .models import Company +from .models import SupplierPart, SupplierPriceBreak + from .serializers import CompanySerializer +from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer class CompanyList(generics.ListCreateAPIView): diff --git a/InvenTree/company/forms.py b/InvenTree/company/forms.py index 679fe323ee..0ad63b9d63 100644 --- a/InvenTree/company/forms.py +++ b/InvenTree/company/forms.py @@ -8,6 +8,8 @@ from __future__ import unicode_literals from InvenTree.forms import HelperForm from .models import Company +from .models import SupplierPart +from .models import SupplierPriceBreak class EditCompanyForm(HelperForm): @@ -37,3 +39,37 @@ class CompanyImageForm(HelperForm): fields = [ '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' + ] diff --git a/InvenTree/company/migrations/0007_supplierpart_supplierpricebreak.py b/InvenTree/company/migrations/0007_supplierpart_supplierpricebreak.py new file mode 100644 index 0000000000..f3cc3ea6de --- /dev/null +++ b/InvenTree/company/migrations/0007_supplierpart_supplierpricebreak.py @@ -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')}, + }, + ), + ] diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 63fcf4add8..06aa100f26 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -7,6 +7,8 @@ from __future__ import unicode_literals import os +from django.core.validators import MinValueValidator + from django.apps import apps from django.db import models from django.urls import reverse @@ -150,3 +152,174 @@ class Contact(models.Model): 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: 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) diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index 2967dbebd5..b09bccd234 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -5,6 +5,9 @@ JSON serializers for Company app from rest_framework import serializers from .models import Company +from .models import SupplierPart, SupplierPriceBreak + +from part.serializers import PartBriefSerializer class CompanyBriefSerializer(serializers.ModelSerializer): @@ -47,3 +50,43 @@ class CompanySerializer(serializers.ModelSerializer): 'is_supplier', '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' + ] diff --git a/InvenTree/company/urls.py b/InvenTree/company/urls.py index 0f8719fe01..5b914e6cd9 100644 --- a/InvenTree/company/urls.py +++ b/InvenTree/company/urls.py @@ -36,3 +36,23 @@ company_urls = [ # Redirect any other patterns 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\d+)/edit/', views.PriceBreakEdit.as_view(), name='price-break-edit'), + url(r'^(?P\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\d+)/', include(supplier_part_detail_urls)), +] \ No newline at end of file diff --git a/InvenTree/company/views.py b/InvenTree/company/views.py index 0630556946..6a45b8c2d6 100644 --- a/InvenTree/company/views.py +++ b/InvenTree/company/views.py @@ -8,12 +8,18 @@ from __future__ import unicode_literals from django.views.generic import DetailView, ListView +from django.forms import HiddenInput + from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView from .models import Company +from .models import SupplierPart +from .models import SupplierPriceBreak from .forms import EditCompanyForm from .forms import CompanyImageForm +from .forms import EditSupplierPartForm +from .forms import EditPriceBreakForm class CompanyIndex(ListView): @@ -104,3 +110,142 @@ class CompanyDelete(AjaxDeleteView): return { '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' diff --git a/InvenTree/part/__init__.py b/InvenTree/part/__init__.py index da73be7753..e5912481ff 100644 --- a/InvenTree/part/__init__.py +++ b/InvenTree/part/__init__.py @@ -5,6 +5,5 @@ It includes models for: - PartCategory - Part -- SupplierPart - BomItem """ diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index 751c9176a5..5705abaa61 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -3,8 +3,6 @@ from import_export.admin import ImportExportModelAdmin from .models import PartCategory, Part from .models import PartAttachment, PartStar -from .models import SupplierPart -from .models import SupplierPriceBreak from .models import BomItem @@ -32,14 +30,6 @@ class BomItemAdmin(ImportExportModelAdmin): 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): list_display = ('name', 'units', 'format') @@ -54,5 +44,4 @@ admin.site.register(PartCategory, PartCategoryAdmin) admin.site.register(PartAttachment, PartAttachmentAdmin) admin.site.register(PartStar, PartStarAdmin) admin.site.register(BomItem, BomItemAdmin) -admin.site.register(SupplierPart, SupplierPartAdmin) -admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin) + diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 0973138b21..889fae8111 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -15,11 +15,13 @@ from rest_framework import generics, permissions from django.conf.urls import url, include 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 SupplierPart, SupplierPriceBreak from .serializers import PartSerializer, BomItemSerializer -from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer from .serializers import CategorySerializer from .serializers import PartStarSerializer diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 96fec2a597..7f8854489c 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -11,8 +11,6 @@ from django import forms from .models import Part, PartCategory, PartAttachment from .models import BomItem -from .models import SupplierPart -from .models import SupplierPriceBreak class PartImageForm(HelperForm): @@ -140,37 +138,3 @@ class EditBomItemForm(HelperForm): # Prevent editing of the part associated with this BomItem 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' - ] \ No newline at end of file diff --git a/InvenTree/part/migrations/0032_auto_20190518_1759.py b/InvenTree/part/migrations/0032_auto_20190518_1759.py new file mode 100644 index 0000000000..8a82961947 --- /dev/null +++ b/InvenTree/part/migrations/0032_auto_20190518_1759.py @@ -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', + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index ad210c7330..62fb6e7553 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -32,7 +32,8 @@ import hashlib from InvenTree import helpers from InvenTree import validators from InvenTree.models import InvenTreeTree -from company.models import Company + +from company.models import SupplierPart class PartCategory(InvenTreeTree): @@ -317,7 +318,7 @@ class Part(models.Model): # Default to None if there are multiple suppliers to choose from return None - default_supplier = models.ForeignKey('part.SupplierPart', + default_supplier = models.ForeignKey(SupplierPart, on_delete=models.SET_NULL, blank=True, null=True, help_text='Default supplier part', @@ -800,167 +801,3 @@ class BomItem(models.Model): 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) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 87ca59c13b..947fa9904b 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -5,7 +5,7 @@ JSON serializers for Part app from rest_framework import serializers from .models import Part, PartStar -from .models import SupplierPart, SupplierPriceBreak + from .models import PartCategory from .models import BomItem @@ -119,43 +119,3 @@ class BomItemSerializer(InvenTreeModelSerializer): 'overage', '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' - ] diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 0d02c01918..89453c63f9 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -12,26 +12,6 @@ from django.conf.urls import url, include from . import views -price_break_urls = [ - url('^new/', views.PriceBreakCreate.as_view(), name='price-break-create'), - - url(r'^(?P\d+)/edit/', views.PriceBreakEdit.as_view(), name='price-break-edit'), - url(r'^(?P\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\d+)/', include(supplier_part_detail_urls)), -] - part_attachment_urls = [ url('^new/?', views.PartAttachmentCreate.as_view(), name='part-attachment-create'), url(r'^(?P\d+)/edit/?', views.PartAttachmentEdit.as_view(), name='part-attachment-edit'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 61dffe986b..5cc57150e4 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -15,8 +15,6 @@ from django.forms import HiddenInput, CheckboxInput from company.models import Company from .models import PartCategory, Part, PartAttachment from .models import BomItem -from .models import SupplierPart -from .models import SupplierPriceBreak from .models import match_part_names from . import forms as part_forms @@ -732,142 +730,3 @@ class BomItemDelete(AjaxDeleteView): ajax_template_name = 'part/bom-delete.html' context_object_name = 'item' 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' diff --git a/InvenTree/stock/migrations/0017_auto_20190518_1759.py b/InvenTree/stock/migrations/0017_auto_20190518_1759.py new file mode 100644 index 0000000000..c0e583999e --- /dev/null +++ b/InvenTree/stock/migrations/0017_auto_20190518_1759.py @@ -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'), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 950d6237dd..2e48ea167f 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -22,6 +22,7 @@ from InvenTree import helpers from InvenTree.models import InvenTreeTree from part.models import Part +from company.models import SupplierPart 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') - 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') location = models.ForeignKey(StockLocation, on_delete=models.DO_NOTHING, diff --git a/inventree_db.sqlite3.backup b/inventree_db.sqlite3.backup new file mode 100644 index 0000000000..0ec1ba7feb Binary files /dev/null and b/inventree_db.sqlite3.backup differ