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 @@
Manufacturer | {{ part.manufacturer }} |
MPN | {{ part.MPN }} |
{% endif %}
+{% if part.note %}
+ Note | {{ part.note }} |
+{% endif %}
+ Pricing |
+ Single Price | {{ part.single_price }} |
+{% if part.multiple > 1 %}
+ Order Multiple | {{ part.multiple }} |
+{% endif %}
+{% if part.base_cost > 0 %}
+ Base Price (Flat Fee) | {{ part.base_cost }} |
+{% endif %}
+{% if part.minimum > 1 %}
+ Minimum Order Quantity | {{ part.minimum }} |
+{% endif %}
+Price Breaks
+
+
+
+ Quantity |
+ Price |
+
+{% for pb in part.price_breaks.all %}
+
+ {{ pb.quantity }} |
+ {{ pb.cost }} |
+
+{% endfor %}
+
+
+
+
+
+
{% include 'modals.html' %}
{% endblock %}
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/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/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/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/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/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 306053762a..1d2f447083 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
@@ -456,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={'purchaseable': True},
+ )
supplier = models.ForeignKey(Company, on_delete=models.CASCADE,
related_name='parts')
@@ -469,22 +473,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(1)], help_text='Order multiple')
# Mimumum number required to order
- minimum = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)])
+ 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)
@@ -501,6 +508,50 @@ 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
+ - 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 +565,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")
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index 5e2596d126..378dbe4732 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'
+ ]
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index 8bdeaea8bb..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):