From cd0b6a65117038e215b33c90aaeeca8a12a22e42 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Apr 2019 00:09:39 +1000 Subject: [PATCH 1/6] Updated Supplier models - Added cost calculation for supplier part - Added more validators and help text --- .../migrations/0006_auto_20190416_2354.py | 30 +++++++++ .../migrations/0007_auto_20190417_0007.py | 54 +++++++++++++++ InvenTree/part/models.py | 65 ++++++++++++++++--- 3 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 InvenTree/part/migrations/0006_auto_20190416_2354.py create mode 100644 InvenTree/part/migrations/0007_auto_20190417_0007.py diff --git a/InvenTree/part/migrations/0006_auto_20190416_2354.py b/InvenTree/part/migrations/0006_auto_20190416_2354.py new file mode 100644 index 0000000000..f2b2f42398 --- /dev/null +++ b/InvenTree/part/migrations/0006_auto_20190416_2354.py @@ -0,0 +1,30 @@ +# Generated by Django 2.2 on 2019-04-16 13:54 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0005_part_consumable'), + ] + + operations = [ + migrations.AlterField( + model_name='bomitem', + name='sub_part', + field=models.ForeignKey(limit_choices_to={'consumable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='used_in', to='part.Part'), + ), + migrations.AlterField( + model_name='part', + name='consumable', + field=models.BooleanField(default=True, help_text='Can this part be used to build other parts?'), + ), + migrations.AlterField( + model_name='supplierpricebreak', + name='quantity', + field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(2)]), + ), + ] diff --git a/InvenTree/part/migrations/0007_auto_20190417_0007.py b/InvenTree/part/migrations/0007_auto_20190417_0007.py new file mode 100644 index 0000000000..554a471132 --- /dev/null +++ b/InvenTree/part/migrations/0007_auto_20190417_0007.py @@ -0,0 +1,54 @@ +# Generated by Django 2.2 on 2019-04-16 14:07 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0006_auto_20190416_2354'), + ] + + operations = [ + migrations.AddField( + model_name='supplierpart', + name='note', + field=models.CharField(blank=True, help_text='Notes', max_length=100), + ), + migrations.AlterField( + model_name='supplierpart', + name='base_cost', + field=models.DecimalField(decimal_places=3, default=0, help_text='Minimum charge (e.g. stocking fee)', max_digits=10, validators=[django.core.validators.MinValueValidator(0)]), + ), + migrations.AlterField( + model_name='supplierpart', + name='description', + field=models.CharField(blank=True, help_text='Supplier part description', max_length=250), + ), + migrations.AlterField( + model_name='supplierpart', + name='minimum', + field=models.PositiveIntegerField(default=1, help_text='Minimum order quantity (MOQ)', validators=[django.core.validators.MinValueValidator(0)]), + ), + migrations.AlterField( + model_name='supplierpart', + name='multiple', + field=models.PositiveIntegerField(default=1, help_text='Order multiple', validators=[django.core.validators.MinValueValidator(0)]), + ), + migrations.AlterField( + model_name='supplierpart', + name='packaging', + field=models.CharField(blank=True, help_text='Part packaging', max_length=50), + ), + migrations.AlterField( + model_name='supplierpart', + name='single_price', + field=models.DecimalField(decimal_places=3, default=0, help_text='Price for single quantity', max_digits=10, validators=[django.core.validators.MinValueValidator(0)]), + ), + migrations.AlterField( + model_name='supplierpricebreak', + name='cost', + field=models.DecimalField(decimal_places=3, max_digits=10, validators=[django.core.validators.MinValueValidator(0)]), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 306053762a..a1decb1069 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -3,6 +3,8 @@ from __future__ import unicode_literals import os +import math + from django.utils.translation import gettext_lazy as _ from django.core.exceptions import ValidationError @@ -469,22 +471,25 @@ class SupplierPart(models.Model): URL = models.URLField(blank=True) - description = models.CharField(max_length=250, blank=True) + description = models.CharField(max_length=250, blank=True, help_text='Supplier part description') + + # Note attached to this BOM line item + note = models.CharField(max_length=100, blank=True, help_text='Notes') # Default price for a single unit - single_price = models.DecimalField(max_digits=10, decimal_places=3, default=0) + single_price = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], help_text='Price for single quantity') # Base charge added to order independent of quantity e.g. "Reeling Fee" - base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0) + base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], help_text='Minimum charge (e.g. stocking fee)') # packaging that the part is supplied in, e.g. "Reel" - packaging = models.CharField(max_length=50, blank=True) + packaging = models.CharField(max_length=50, blank=True, help_text='Part packaging') # multiple that the part is provided in - multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)]) + multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)], help_text='Order multiple') # Mimumum number required to order - minimum = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)]) + minimum = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)], help_text='Minimum order quantity (MOQ)') # lead time for parts that cannot be delivered immediately lead_time = models.DurationField(blank=True, null=True) @@ -501,6 +506,47 @@ class SupplierPart(models.Model): return ' | '.join(items) + def get_price(self, quantity, moq=True, multiples=True): + """ Calculate the supplier price based on quantity price breaks. + - If no price breaks available, use the single_price field + - 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 + """ + + # Order multiples + if multiples: + quantity = int(math.ceil(quantity / self.multipe) * self.multiple) + + # Minimum ordering requirement + if moq and self.minimum > quantity: + quantity = self.minimum + + pb_found = False + pb_quantity = -1 + pb_cost = 0.0 + + for pb in self.price_breaks.all(): + # Ignore this pricebreak! + 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 + + # No appropriate price-break found - use the single cost! + if pb_found: + cost = pb_cost * quantity + else: + cost = self.single_price * quantity + + return cost + self.base_cost + + def __str__(self): return "{sku} - {supplier}".format( sku=self.SKU, @@ -514,8 +560,11 @@ class SupplierPriceBreak(models.Model): """ part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='price_breaks') - quantity = models.PositiveIntegerField(validators=[MinValueValidator(0)]) - cost = models.DecimalField(max_digits=10, decimal_places=3) + + # At least 2 units are required for a 'price break' - Otherwise, just use single-price! + quantity = models.PositiveIntegerField(validators=[MinValueValidator(2)]) + + cost = models.DecimalField(max_digits=10, decimal_places=3, validators=[MinValueValidator(0)]) class Meta: unique_together = ("part", "quantity") From 5cd837be07dce05a05b361852f7b09cee301645f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Apr 2019 00:14:22 +1000 Subject: [PATCH 2/6] Limit supplierpart part link to purchasable parts only --- .../migrations/0008_auto_20190417_0013.py | 19 +++++++++++++++++++ InvenTree/part/models.py | 4 +++- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 InvenTree/part/migrations/0008_auto_20190417_0013.py diff --git a/InvenTree/part/migrations/0008_auto_20190417_0013.py b/InvenTree/part/migrations/0008_auto_20190417_0013.py new file mode 100644 index 0000000000..e7311e2be8 --- /dev/null +++ b/InvenTree/part/migrations/0008_auto_20190417_0013.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2 on 2019-04-16 14:13 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0007_auto_20190417_0007'), + ] + + operations = [ + migrations.AlterField( + model_name='supplierpart', + name='part', + field=models.ForeignKey(limit_choices_to={'purchasable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='supplier_parts', to='part.Part'), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index a1decb1069..c002143315 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -458,7 +458,9 @@ class SupplierPart(models.Model): # Link to an actual part # The part will have a field 'supplier_parts' which links to the supplier part options part = models.ForeignKey(Part, on_delete=models.CASCADE, - related_name='supplier_parts') + related_name='supplier_parts', + limit_choices_to={'purchasable': True}, + ) supplier = models.ForeignKey(Company, on_delete=models.CASCADE, related_name='parts') From a3b544e2a4c753f1fb7018213493b846149066d7 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Apr 2019 00:21:11 +1000 Subject: [PATCH 3/6] typo fix --- .../migrations/0009_auto_20190417_0019.py | 19 +++++++++++++++++++ InvenTree/part/models.py | 2 +- InvenTree/part/views.py | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 InvenTree/part/migrations/0009_auto_20190417_0019.py diff --git a/InvenTree/part/migrations/0009_auto_20190417_0019.py b/InvenTree/part/migrations/0009_auto_20190417_0019.py new file mode 100644 index 0000000000..2a6eba4960 --- /dev/null +++ b/InvenTree/part/migrations/0009_auto_20190417_0019.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2 on 2019-04-16 14:19 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0008_auto_20190417_0013'), + ] + + operations = [ + migrations.AlterField( + model_name='supplierpart', + name='part', + field=models.ForeignKey(limit_choices_to={'purchaseable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='supplier_parts', to='part.Part'), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index c002143315..8b5755550e 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -459,7 +459,7 @@ class SupplierPart(models.Model): # The part will have a field 'supplier_parts' which links to the supplier part options part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='supplier_parts', - limit_choices_to={'purchasable': True}, + limit_choices_to={'purchaseable': True}, ) supplier = models.ForeignKey(Company, on_delete=models.CASCADE, diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 8bdeaea8bb..92c1d5f2da 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -341,7 +341,7 @@ class SupplierPartCreate(AjaxCreateView): form_class = EditSupplierPartForm ajax_template_name = 'modal_form.html' ajax_form_title = 'Create new Supplier Part' - template_name = 'company/partcreate.html' + #template_name = 'company/partcreate.html' context_object_name = 'part' def get_initial(self): From 150bc1e674d202ef176ee6247c87d4ab63901389 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Apr 2019 00:35:49 +1000 Subject: [PATCH 4/6] Add API interface for SupplierPriceBreak --- InvenTree/part/api.py | 23 +++++++++++++++++++++-- InvenTree/part/models.py | 4 ++++ InvenTree/part/serializers.py | 14 +++++++++++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index bbb44a14e6..d849506c06 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -10,10 +10,10 @@ from django.conf.urls import url, include from django.shortcuts import get_object_or_404 from .models import Part, PartCategory, BomItem -from .models import SupplierPart +from .models import SupplierPart, SupplierPriceBreak from .serializers import PartSerializer, BomItemSerializer -from .serializers import SupplierPartSerializer +from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer from .serializers import CategorySerializer from InvenTree.views import TreeSerializer @@ -168,6 +168,24 @@ class SupplierPartList(generics.ListAPIView): ] +class SupplierPriceBreakList(generics.ListCreateAPIView): + + queryset = SupplierPriceBreak.objects.all() + serializer_class = SupplierPriceBreakSerializer + + permission_classes = [ + permissions.IsAuthenticatedOrReadOnly, + ] + + filter_backends = [ + DjangoFilterBackend, + ] + + filter_fields = [ + 'part', + ] + + cat_api_urls = [ url(r'^$', CategoryList.as_view(), name='api-part-category-list'), ] @@ -177,6 +195,7 @@ part_api_urls = [ url(r'^category/', include(cat_api_urls)), + url(r'^price-break/?', SupplierPriceBreakList.as_view(), name='api-part-supplier-price'), url(r'^supplier/?', SupplierPartList.as_view(), name='api-part-supplier-list'), url(r'^bom/?', BomList.as_view(), name='api-bom-list'), diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 8b5755550e..c7d6f8903f 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -508,6 +508,10 @@ class SupplierPart(models.Model): return ' | '.join(items) + @property + def has_price_breaks(self): + return self.price_breaks.count() > 0 + def get_price(self, quantity, moq=True, multiples=True): """ Calculate the supplier price based on quantity price breaks. - If no price breaks available, use the single_price field diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 5e2596d126..26dc985666 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from .models import Part, PartCategory, BomItem -from .models import SupplierPart +from .models import SupplierPart, SupplierPriceBreak from company.serializers import CompanyBriefSerializer @@ -104,3 +104,15 @@ class SupplierPartSerializer(serializers.ModelSerializer): 'manufacturer', 'MPN', ] + + +class SupplierPriceBreakSerializer(serializers.ModelSerializer): + + class Meta: + model = SupplierPriceBreak + fields = [ + 'pk', + 'part', + 'quantity', + 'cost' + ] \ No newline at end of file From 52c3a63c61fd776fd1959c4f150e5b989f9cc5b2 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Apr 2019 00:48:01 +1000 Subject: [PATCH 5/6] Updated SupplierPriceBreak page --- .../company/templates/company/index.html | 2 +- .../company/templates/company/partdetail.html | 33 +++++++++++++++++++ InvenTree/part/forms.py | 7 ++++ .../migrations/0010_auto_20190417_0045.py | 24 ++++++++++++++ InvenTree/part/models.py | 4 +-- 5 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 InvenTree/part/migrations/0010_auto_20190417_0045.py diff --git a/InvenTree/company/templates/company/index.html b/InvenTree/company/templates/company/index.html index bb2ee3413c..a9cb7e2700 100644 --- a/InvenTree/company/templates/company/index.html +++ b/InvenTree/company/templates/company/index.html @@ -6,7 +6,7 @@

Companies

-

+
diff --git a/InvenTree/company/templates/company/partdetail.html b/InvenTree/company/templates/company/partdetail.html index 0710e10053..644dc5760e 100644 --- a/InvenTree/company/templates/company/partdetail.html +++ b/InvenTree/company/templates/company/partdetail.html @@ -44,10 +44,43 @@ {% endif %} +{% if part.note %} + +{% endif %} + + +{% if part.multiple > 1 %} + +{% endif %} +{% if part.base_cost > 0 %} + +{% endif %} +{% if part.minimum > 1 %} + +{% endif %}
Manufacturer{{ part.manufacturer }}
MPN{{ part.MPN }}
Note{{ part.note }}
Pricing
Single Price{{ part.single_price }}
Order Multiple{{ part.multiple }}
Base Price (Flat Fee){{ part.base_cost }}
Minimum Order Quantity{{ part.minimum }}

+

Price Breaks

+ + + + + + +{% for pb in part.price_breaks.all %} + + + + +{% endfor %} +
QuantityPrice
{{ pb.quantity }}{{ pb.cost }}
+ +
+ +
+ {% include 'modals.html' %} {% endblock %} diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 977d6e5ef3..28d9941326 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -99,4 +99,11 @@ class EditSupplierPartForm(HelperForm): 'URL', 'manufacturer', 'MPN', + 'note', + 'single_price', + 'base_cost', + 'multiple', + 'minimum', + 'packaging', + 'lead_time' ] diff --git a/InvenTree/part/migrations/0010_auto_20190417_0045.py b/InvenTree/part/migrations/0010_auto_20190417_0045.py new file mode 100644 index 0000000000..1040afc67c --- /dev/null +++ b/InvenTree/part/migrations/0010_auto_20190417_0045.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2 on 2019-04-16 14:45 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0009_auto_20190417_0019'), + ] + + operations = [ + migrations.AlterField( + model_name='supplierpart', + name='minimum', + field=models.PositiveIntegerField(default=1, help_text='Minimum order quantity (MOQ)', validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AlterField( + model_name='supplierpart', + name='multiple', + field=models.PositiveIntegerField(default=1, help_text='Order multiple', validators=[django.core.validators.MinValueValidator(1)]), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index c7d6f8903f..800ca5e41a 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -488,10 +488,10 @@ class SupplierPart(models.Model): packaging = models.CharField(max_length=50, blank=True, help_text='Part packaging') # multiple that the part is provided in - multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)], help_text='Order multiple') + multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text='Order multiple') # Mimumum number required to order - minimum = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)], help_text='Minimum order quantity (MOQ)') + minimum = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], help_text='Minimum order quantity (MOQ)') # lead time for parts that cannot be delivered immediately lead_time = models.DurationField(blank=True, null=True) From 19a11eac1a45567088c847f8e92efa79b223658f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 17 Apr 2019 07:49:46 +1000 Subject: [PATCH 6/6] PEP fixes --- InvenTree/part/models.py | 5 ++--- InvenTree/part/serializers.py | 2 +- InvenTree/part/views.py | 1 - 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 800ca5e41a..1d2f447083 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -516,7 +516,7 @@ class SupplierPart(models.Model): """ Calculate the supplier price based on quantity price breaks. - If no price breaks available, use the single_price field - Don't forget to add in flat-fee cost (base_cost field) - - If MOQ (minimum order quantity) is required, bump quantity + - 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 """ @@ -552,7 +552,6 @@ class SupplierPart(models.Model): return cost + self.base_cost - def __str__(self): return "{sku} - {supplier}".format( sku=self.SKU, @@ -566,7 +565,7 @@ class SupplierPriceBreak(models.Model): """ part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='price_breaks') - + # At least 2 units are required for a 'price break' - Otherwise, just use single-price! quantity = models.PositiveIntegerField(validators=[MinValueValidator(2)]) diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 26dc985666..378dbe4732 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -115,4 +115,4 @@ class SupplierPriceBreakSerializer(serializers.ModelSerializer): 'part', 'quantity', 'cost' - ] \ No newline at end of file + ] diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 92c1d5f2da..31bedc5c56 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -341,7 +341,6 @@ class SupplierPartCreate(AjaxCreateView): form_class = EditSupplierPartForm ajax_template_name = 'modal_form.html' ajax_form_title = 'Create new Supplier Part' - #template_name = 'company/partcreate.html' context_object_name = 'part' def get_initial(self):