Merge pull request #128 from SchrodingersGat/price-breaks

Price breaks
This commit is contained in:
Oliver 2019-04-17 07:52:29 +10:00 committed by GitHub
commit 3c7191c8c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 284 additions and 14 deletions

View File

@ -6,7 +6,7 @@
<h3>Companies</h3> <h3>Companies</h3>
<div id='button-toolbar'> <div id='button-toolbar'>
<h3><button style='float: right;' class="btn btn-success" id='new-company'>New Company</button></h3> <button style='float: right;' class="btn btn-success" id='new-company'>New Company</button>
</div> </div>
<table class='table table-striped' id='company-table' data-toolbar='#button-toolbar'> <table class='table table-striped' id='company-table' data-toolbar='#button-toolbar'>

View File

@ -44,10 +44,43 @@
<tr><td>Manufacturer</td><td>{{ part.manufacturer }}</td></tr> <tr><td>Manufacturer</td><td>{{ part.manufacturer }}</td></tr>
<tr><td>MPN</td><td>{{ part.MPN }}</td></tr> <tr><td>MPN</td><td>{{ part.MPN }}</td></tr>
{% endif %} {% endif %}
{% if part.note %}
<tr><td>Note</td><td>{{ part.note }}</td></tr>
{% endif %}
<tr><th colspan='2'>Pricing</th></tr>
<tr><td>Single Price</td><td>{{ part.single_price }}</td></tr>
{% if part.multiple > 1 %}
<tr><td>Order Multiple</td><td>{{ part.multiple }}</td></tr>
{% endif %}
{% if part.base_cost > 0 %}
<tr><td>Base Price (Flat Fee)</td><td>{{ part.base_cost }}</td></tr>
{% endif %}
{% if part.minimum > 1 %}
<tr><td>Minimum Order Quantity</td><td>{{ part.minimum }}</td></tr>
{% endif %}
</table> </table>
<br> <br>
<h3>Price Breaks</h3>
<table class="table table-striped table-condensed">
<tr>
<th>Quantity</th>
<th>Price</th>
</tr>
{% for pb in part.price_breaks.all %}
<tr>
<td>{{ pb.quantity }}</td>
<td>{{ pb.cost }}</td>
</tr>
{% endfor %}
</table>
<div>
<button class='btn btn-primary' type='button'>New Price Break</button>
</div>
{% include 'modals.html' %} {% include 'modals.html' %}
{% endblock %} {% endblock %}

View File

@ -10,10 +10,10 @@ from django.conf.urls import url, include
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from .models import Part, PartCategory, BomItem from .models import Part, PartCategory, BomItem
from .models import SupplierPart from .models import SupplierPart, SupplierPriceBreak
from .serializers import PartSerializer, BomItemSerializer from .serializers import PartSerializer, BomItemSerializer
from .serializers import SupplierPartSerializer from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
from .serializers import CategorySerializer from .serializers import CategorySerializer
from InvenTree.views import TreeSerializer 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 = [ cat_api_urls = [
url(r'^$', CategoryList.as_view(), name='api-part-category-list'), 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'^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'^supplier/?', SupplierPartList.as_view(), name='api-part-supplier-list'),
url(r'^bom/?', BomList.as_view(), name='api-bom-list'), url(r'^bom/?', BomList.as_view(), name='api-bom-list'),

View File

@ -99,4 +99,11 @@ class EditSupplierPartForm(HelperForm):
'URL', 'URL',
'manufacturer', 'manufacturer',
'MPN', 'MPN',
'note',
'single_price',
'base_cost',
'multiple',
'minimum',
'packaging',
'lead_time'
] ]

View File

@ -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)]),
),
]

View File

@ -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)]),
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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)]),
),
]

View File

@ -3,6 +3,8 @@ from __future__ import unicode_literals
import os import os
import math
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -456,7 +458,9 @@ class SupplierPart(models.Model):
# Link to an actual part # Link to an actual part
# The part will have a field 'supplier_parts' which links to the supplier part options # The part will have a field 'supplier_parts' which links to the supplier part options
part = models.ForeignKey(Part, on_delete=models.CASCADE, 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, supplier = models.ForeignKey(Company, on_delete=models.CASCADE,
related_name='parts') related_name='parts')
@ -469,22 +473,25 @@ class SupplierPart(models.Model):
URL = models.URLField(blank=True) 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 # 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 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 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 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 # 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 for parts that cannot be delivered immediately
lead_time = models.DurationField(blank=True, null=True) lead_time = models.DurationField(blank=True, null=True)
@ -501,6 +508,50 @@ class SupplierPart(models.Model):
return ' | '.join(items) 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): def __str__(self):
return "{sku} - {supplier}".format( return "{sku} - {supplier}".format(
sku=self.SKU, sku=self.SKU,
@ -514,8 +565,11 @@ class SupplierPriceBreak(models.Model):
""" """
part = models.ForeignKey(SupplierPart, on_delete=models.CASCADE, related_name='price_breaks') 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: class Meta:
unique_together = ("part", "quantity") unique_together = ("part", "quantity")

View File

@ -1,7 +1,7 @@
from rest_framework import serializers from rest_framework import serializers
from .models import Part, PartCategory, BomItem from .models import Part, PartCategory, BomItem
from .models import SupplierPart from .models import SupplierPart, SupplierPriceBreak
from company.serializers import CompanyBriefSerializer from company.serializers import CompanyBriefSerializer
@ -104,3 +104,15 @@ class SupplierPartSerializer(serializers.ModelSerializer):
'manufacturer', 'manufacturer',
'MPN', 'MPN',
] ]
class SupplierPriceBreakSerializer(serializers.ModelSerializer):
class Meta:
model = SupplierPriceBreak
fields = [
'pk',
'part',
'quantity',
'cost'
]

View File

@ -341,7 +341,6 @@ class SupplierPartCreate(AjaxCreateView):
form_class = EditSupplierPartForm form_class = EditSupplierPartForm
ajax_template_name = 'modal_form.html' ajax_template_name = 'modal_form.html'
ajax_form_title = 'Create new Supplier Part' ajax_form_title = 'Create new Supplier Part'
template_name = 'company/partcreate.html'
context_object_name = 'part' context_object_name = 'part'
def get_initial(self): def get_initial(self):