mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
commit
3c7191c8c8
@ -6,7 +6,7 @@
|
||||
|
||||
<h3>Companies</h3>
|
||||
<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>
|
||||
|
||||
<table class='table table-striped' id='company-table' data-toolbar='#button-toolbar'>
|
||||
|
@ -44,10 +44,43 @@
|
||||
<tr><td>Manufacturer</td><td>{{ part.manufacturer }}</td></tr>
|
||||
<tr><td>MPN</td><td>{{ part.MPN }}</td></tr>
|
||||
{% 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>
|
||||
|
||||
<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' %}
|
||||
|
||||
{% endblock %}
|
||||
|
@ -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'),
|
||||
|
||||
|
@ -99,4 +99,11 @@ class EditSupplierPartForm(HelperForm):
|
||||
'URL',
|
||||
'manufacturer',
|
||||
'MPN',
|
||||
'note',
|
||||
'single_price',
|
||||
'base_cost',
|
||||
'multiple',
|
||||
'minimum',
|
||||
'packaging',
|
||||
'lead_time'
|
||||
]
|
||||
|
30
InvenTree/part/migrations/0006_auto_20190416_2354.py
Normal file
30
InvenTree/part/migrations/0006_auto_20190416_2354.py
Normal 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)]),
|
||||
),
|
||||
]
|
54
InvenTree/part/migrations/0007_auto_20190417_0007.py
Normal file
54
InvenTree/part/migrations/0007_auto_20190417_0007.py
Normal 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)]),
|
||||
),
|
||||
]
|
19
InvenTree/part/migrations/0008_auto_20190417_0013.py
Normal file
19
InvenTree/part/migrations/0008_auto_20190417_0013.py
Normal 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'),
|
||||
),
|
||||
]
|
19
InvenTree/part/migrations/0009_auto_20190417_0019.py
Normal file
19
InvenTree/part/migrations/0009_auto_20190417_0019.py
Normal 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'),
|
||||
),
|
||||
]
|
24
InvenTree/part/migrations/0010_auto_20190417_0045.py
Normal file
24
InvenTree/part/migrations/0010_auto_20190417_0045.py
Normal 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)]),
|
||||
),
|
||||
]
|
@ -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")
|
||||
|
@ -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'
|
||||
]
|
||||
|
@ -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):
|
||||
|
Loading…
Reference in New Issue
Block a user