mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
commit
43478a0be7
@ -205,6 +205,20 @@ class InvenTreeSetting(models.Model):
|
|||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'PART_INTERNAL_PRICE': {
|
||||||
|
'name': _('Internal Prices'),
|
||||||
|
'description': _('Enable internal prices for parts'),
|
||||||
|
'default': False,
|
||||||
|
'validator': bool
|
||||||
|
},
|
||||||
|
|
||||||
|
'PART_BOM_USE_INTERNAL_PRICE': {
|
||||||
|
'name': _('Internal Price as BOM-Price'),
|
||||||
|
'description': _('Use the internal price (if set) in BOM-price calculations'),
|
||||||
|
'default': False,
|
||||||
|
'validator': bool
|
||||||
|
},
|
||||||
|
|
||||||
'REPORT_DEBUG_MODE': {
|
'REPORT_DEBUG_MODE': {
|
||||||
'name': _('Debug Mode'),
|
'name': _('Debug Mode'),
|
||||||
'description': _('Generate reports in debug mode (HTML output)'),
|
'description': _('Generate reports in debug mode (HTML output)'),
|
||||||
@ -726,7 +740,7 @@ class PriceBreak(models.Model):
|
|||||||
return converted.amount
|
return converted.amount
|
||||||
|
|
||||||
|
|
||||||
def get_price(instance, quantity, moq=True, multiples=True, currency=None):
|
def get_price(instance, quantity, moq=True, multiples=True, currency=None, break_name: str = 'price_breaks'):
|
||||||
""" Calculate the price based on quantity price breaks.
|
""" Calculate the price based on quantity price breaks.
|
||||||
|
|
||||||
- Don't forget to add in flat-fee cost (base_cost field)
|
- Don't forget to add in flat-fee cost (base_cost field)
|
||||||
@ -734,7 +748,10 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None):
|
|||||||
- If order multiples are to be observed, then we need to calculate based on that, too
|
- If order multiples are to be observed, then we need to calculate based on that, too
|
||||||
"""
|
"""
|
||||||
|
|
||||||
price_breaks = instance.price_breaks.all()
|
if hasattr(instance, break_name):
|
||||||
|
price_breaks = getattr(instance, break_name).all()
|
||||||
|
else:
|
||||||
|
price_breaks = []
|
||||||
|
|
||||||
# No price break information available?
|
# No price break information available?
|
||||||
if len(price_breaks) == 0:
|
if len(price_breaks) == 0:
|
||||||
@ -756,7 +773,7 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None):
|
|||||||
currency = currency_code_default()
|
currency = currency_code_default()
|
||||||
|
|
||||||
pb_min = None
|
pb_min = None
|
||||||
for pb in instance.price_breaks.all():
|
for pb in price_breaks:
|
||||||
# Store smallest price break
|
# Store smallest price break
|
||||||
if not pb_min:
|
if not pb_min:
|
||||||
pb_min = pb
|
pb_min = pb
|
||||||
|
@ -14,7 +14,7 @@ from .models import BomItem
|
|||||||
from .models import PartParameterTemplate, PartParameter
|
from .models import PartParameterTemplate, PartParameter
|
||||||
from .models import PartCategoryParameterTemplate
|
from .models import PartCategoryParameterTemplate
|
||||||
from .models import PartTestTemplate
|
from .models import PartTestTemplate
|
||||||
from .models import PartSellPriceBreak
|
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||||
|
|
||||||
from stock.models import StockLocation
|
from stock.models import StockLocation
|
||||||
from company.models import SupplierPart
|
from company.models import SupplierPart
|
||||||
@ -286,6 +286,14 @@ class PartSellPriceBreakAdmin(admin.ModelAdmin):
|
|||||||
list_display = ('part', 'quantity', 'price',)
|
list_display = ('part', 'quantity', 'price',)
|
||||||
|
|
||||||
|
|
||||||
|
class PartInternalPriceBreakAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PartInternalPriceBreak
|
||||||
|
|
||||||
|
list_display = ('part', 'quantity', 'price',)
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(Part, PartAdmin)
|
admin.site.register(Part, PartAdmin)
|
||||||
admin.site.register(PartCategory, PartCategoryAdmin)
|
admin.site.register(PartCategory, PartCategoryAdmin)
|
||||||
admin.site.register(PartRelated, PartRelatedAdmin)
|
admin.site.register(PartRelated, PartRelatedAdmin)
|
||||||
@ -297,3 +305,4 @@ admin.site.register(PartParameter, ParameterAdmin)
|
|||||||
admin.site.register(PartCategoryParameterTemplate, PartCategoryParameterAdmin)
|
admin.site.register(PartCategoryParameterTemplate, PartCategoryParameterAdmin)
|
||||||
admin.site.register(PartTestTemplate, PartTestTemplateAdmin)
|
admin.site.register(PartTestTemplate, PartTestTemplateAdmin)
|
||||||
admin.site.register(PartSellPriceBreak, PartSellPriceBreakAdmin)
|
admin.site.register(PartSellPriceBreak, PartSellPriceBreakAdmin)
|
||||||
|
admin.site.register(PartInternalPriceBreak, PartInternalPriceBreakAdmin)
|
||||||
|
@ -25,7 +25,7 @@ from django.urls import reverse
|
|||||||
from .models import Part, PartCategory, BomItem
|
from .models import Part, PartCategory, BomItem
|
||||||
from .models import PartParameter, PartParameterTemplate
|
from .models import PartParameter, PartParameterTemplate
|
||||||
from .models import PartAttachment, PartTestTemplate
|
from .models import PartAttachment, PartTestTemplate
|
||||||
from .models import PartSellPriceBreak
|
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||||
from .models import PartCategoryParameterTemplate
|
from .models import PartCategoryParameterTemplate
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
@ -194,6 +194,24 @@ class PartSalePriceList(generics.ListCreateAPIView):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PartInternalPriceList(generics.ListCreateAPIView):
|
||||||
|
"""
|
||||||
|
API endpoint for list view of PartInternalPriceBreak model
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = PartInternalPriceBreak.objects.all()
|
||||||
|
serializer_class = part_serializers.PartInternalPriceSerializer
|
||||||
|
permission_required = 'roles.sales_order.show'
|
||||||
|
|
||||||
|
filter_backends = [
|
||||||
|
DjangoFilterBackend
|
||||||
|
]
|
||||||
|
|
||||||
|
filter_fields = [
|
||||||
|
'part',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class PartAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
class PartAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
|
||||||
"""
|
"""
|
||||||
API endpoint for listing (and creating) a PartAttachment (file upload).
|
API endpoint for listing (and creating) a PartAttachment (file upload).
|
||||||
@ -1017,6 +1035,11 @@ part_api_urls = [
|
|||||||
url(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
|
url(r'^.*$', PartSalePriceList.as_view(), name='api-part-sale-price-list'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
|
# Base URL for part internal pricing
|
||||||
|
url(r'^internal-price/', include([
|
||||||
|
url(r'^.*$', PartInternalPriceList.as_view(), name='api-part-internal-price-list'),
|
||||||
|
])),
|
||||||
|
|
||||||
# Base URL for PartParameter API endpoints
|
# Base URL for PartParameter API endpoints
|
||||||
url(r'^parameter/', include([
|
url(r'^parameter/', include([
|
||||||
url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-param-template-list'),
|
url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-param-template-list'),
|
||||||
|
51
InvenTree/part/fixtures/part_pricebreaks.yaml
Normal file
51
InvenTree/part/fixtures/part_pricebreaks.yaml
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# Sell price breaks for parts
|
||||||
|
|
||||||
|
# Price breaks for R_2K2_0805
|
||||||
|
|
||||||
|
- model: part.partsellpricebreak
|
||||||
|
pk: 1
|
||||||
|
fields:
|
||||||
|
part: 3
|
||||||
|
quantity: 1
|
||||||
|
price: 0.15
|
||||||
|
|
||||||
|
- model: part.partsellpricebreak
|
||||||
|
pk: 2
|
||||||
|
fields:
|
||||||
|
part: 3
|
||||||
|
quantity: 10
|
||||||
|
price: 0.10
|
||||||
|
|
||||||
|
|
||||||
|
# Internal price breaks for parts
|
||||||
|
|
||||||
|
# Internal Price breaks for R_2K2_0805
|
||||||
|
|
||||||
|
- model: part.partinternalpricebreak
|
||||||
|
pk: 1
|
||||||
|
fields:
|
||||||
|
part: 3
|
||||||
|
quantity: 1
|
||||||
|
price: 0.08
|
||||||
|
|
||||||
|
- model: part.partinternalpricebreak
|
||||||
|
pk: 2
|
||||||
|
fields:
|
||||||
|
part: 3
|
||||||
|
quantity: 10
|
||||||
|
price: 0.05
|
||||||
|
|
||||||
|
# Internal Price breaks for C_22N_0805
|
||||||
|
- model: part.partinternalpricebreak
|
||||||
|
pk: 3
|
||||||
|
fields:
|
||||||
|
part: 5
|
||||||
|
quantity: 1
|
||||||
|
price: 1
|
||||||
|
|
||||||
|
- model: part.partinternalpricebreak
|
||||||
|
pk: 4
|
||||||
|
fields:
|
||||||
|
part: 5
|
||||||
|
quantity: 24
|
||||||
|
price: 0.5
|
@ -20,7 +20,7 @@ from .models import BomItem
|
|||||||
from .models import PartParameterTemplate, PartParameter
|
from .models import PartParameterTemplate, PartParameter
|
||||||
from .models import PartCategoryParameterTemplate
|
from .models import PartCategoryParameterTemplate
|
||||||
from .models import PartTestTemplate
|
from .models import PartTestTemplate
|
||||||
from .models import PartSellPriceBreak
|
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||||
|
|
||||||
|
|
||||||
class PartModelChoiceField(forms.ModelChoiceField):
|
class PartModelChoiceField(forms.ModelChoiceField):
|
||||||
@ -394,3 +394,19 @@ class EditPartSalePriceBreakForm(HelperForm):
|
|||||||
'quantity',
|
'quantity',
|
||||||
'price',
|
'price',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class EditPartInternalPriceBreakForm(HelperForm):
|
||||||
|
"""
|
||||||
|
Form for creating / editing a internal price for a part
|
||||||
|
"""
|
||||||
|
|
||||||
|
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5, label=_('Quantity'))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PartInternalPriceBreak
|
||||||
|
fields = [
|
||||||
|
'part',
|
||||||
|
'quantity',
|
||||||
|
'price',
|
||||||
|
]
|
||||||
|
30
InvenTree/part/migrations/0067_partinternalpricebreak.py
Normal file
30
InvenTree/part/migrations/0067_partinternalpricebreak.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 3.2 on 2021-06-05 14:13
|
||||||
|
|
||||||
|
import InvenTree.fields
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import djmoney.models.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('part', '0066_bomitem_allow_variants'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PartInternalPriceBreak',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('quantity', InvenTree.fields.RoundingDecimalField(decimal_places=5, default=1, help_text='Price break quantity', max_digits=15, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Quantity')),
|
||||||
|
('price_currency', djmoney.models.fields.CurrencyField(choices=[('AUD', 'Australian Dollar'), ('CAD', 'Canadian Dollar'), ('EUR', 'Euro'), ('NZD', 'New Zealand Dollar'), ('GBP', 'Pound Sterling'), ('USD', 'US Dollar'), ('JPY', 'Yen')], default='USD', editable=False, max_length=3)),
|
||||||
|
('price', djmoney.models.fields.MoneyField(decimal_places=4, default_currency='USD', help_text='Unit price at specified quantity', max_digits=19, null=True, verbose_name='Price')),
|
||||||
|
('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='internalpricebreaks', to='part.part', verbose_name='Part')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('part', 'quantity')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
@ -1544,7 +1544,7 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
return (min_price, max_price)
|
return (min_price, max_price)
|
||||||
|
|
||||||
def get_bom_price_range(self, quantity=1):
|
def get_bom_price_range(self, quantity=1, internal=False):
|
||||||
""" Return the price range of the BOM for this part.
|
""" Return the price range of the BOM for this part.
|
||||||
Adds the minimum price for all components in the BOM.
|
Adds the minimum price for all components in the BOM.
|
||||||
|
|
||||||
@ -1561,7 +1561,7 @@ class Part(MPTTModel):
|
|||||||
print("Warning: Item contains itself in BOM")
|
print("Warning: Item contains itself in BOM")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
prices = item.sub_part.get_price_range(quantity * item.quantity)
|
prices = item.sub_part.get_price_range(quantity * item.quantity, internal=internal)
|
||||||
|
|
||||||
if prices is None:
|
if prices is None:
|
||||||
continue
|
continue
|
||||||
@ -1585,7 +1585,7 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
return (min_price, max_price)
|
return (min_price, max_price)
|
||||||
|
|
||||||
def get_price_range(self, quantity=1, buy=True, bom=True):
|
def get_price_range(self, quantity=1, buy=True, bom=True, internal=False):
|
||||||
|
|
||||||
""" Return the price range for this part. This price can be either:
|
""" Return the price range for this part. This price can be either:
|
||||||
|
|
||||||
@ -1596,8 +1596,13 @@ class Part(MPTTModel):
|
|||||||
Minimum of the supplier price or BOM price. If no pricing available, returns None
|
Minimum of the supplier price or BOM price. If no pricing available, returns None
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# only get internal price if set and should be used
|
||||||
|
if internal and self.has_internal_price_breaks:
|
||||||
|
internal_price = self.get_internal_price(quantity)
|
||||||
|
return internal_price, internal_price
|
||||||
|
|
||||||
buy_price_range = self.get_supplier_price_range(quantity) if buy else None
|
buy_price_range = self.get_supplier_price_range(quantity) if buy else None
|
||||||
bom_price_range = self.get_bom_price_range(quantity) if bom else None
|
bom_price_range = self.get_bom_price_range(quantity, internal=internal) if bom else None
|
||||||
|
|
||||||
if buy_price_range is None:
|
if buy_price_range is None:
|
||||||
return bom_price_range
|
return bom_price_range
|
||||||
@ -1649,6 +1654,22 @@ class Part(MPTTModel):
|
|||||||
price=price
|
price=price
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_internal_price(self, quantity, moq=True, multiples=True, currency=None):
|
||||||
|
return common.models.get_price(self, quantity, moq, multiples, currency, break_name='internal_price_breaks')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_internal_price_breaks(self):
|
||||||
|
return self.internal_price_breaks.count() > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def internal_price_breaks(self):
|
||||||
|
""" Return the associated price breaks in the correct order """
|
||||||
|
return self.internalpricebreaks.order_by('quantity').all()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def internal_unit_pricing(self):
|
||||||
|
return self.get_internal_price(1)
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def copy_bom_from(self, other, clear=True, **kwargs):
|
def copy_bom_from(self, other, clear=True, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -1983,6 +2004,21 @@ class PartSellPriceBreak(common.models.PriceBreak):
|
|||||||
unique_together = ('part', 'quantity')
|
unique_together = ('part', 'quantity')
|
||||||
|
|
||||||
|
|
||||||
|
class PartInternalPriceBreak(common.models.PriceBreak):
|
||||||
|
"""
|
||||||
|
Represents a price break for internally selling this part
|
||||||
|
"""
|
||||||
|
|
||||||
|
part = models.ForeignKey(
|
||||||
|
Part, on_delete=models.CASCADE,
|
||||||
|
related_name='internalpricebreaks',
|
||||||
|
verbose_name=_('Part')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('part', 'quantity')
|
||||||
|
|
||||||
|
|
||||||
class PartStar(models.Model):
|
class PartStar(models.Model):
|
||||||
""" A PartStar object creates a relationship between a User and a Part.
|
""" A PartStar object creates a relationship between a User and a Part.
|
||||||
|
|
||||||
|
@ -17,7 +17,8 @@ from stock.models import StockItem
|
|||||||
|
|
||||||
from .models import (BomItem, Part, PartAttachment, PartCategory,
|
from .models import (BomItem, Part, PartAttachment, PartCategory,
|
||||||
PartParameter, PartParameterTemplate, PartSellPriceBreak,
|
PartParameter, PartParameterTemplate, PartSellPriceBreak,
|
||||||
PartStar, PartTestTemplate, PartCategoryParameterTemplate)
|
PartStar, PartTestTemplate, PartCategoryParameterTemplate,
|
||||||
|
PartInternalPriceBreak)
|
||||||
|
|
||||||
|
|
||||||
class CategorySerializer(InvenTreeModelSerializer):
|
class CategorySerializer(InvenTreeModelSerializer):
|
||||||
@ -100,6 +101,25 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PartInternalPriceSerializer(InvenTreeModelSerializer):
|
||||||
|
"""
|
||||||
|
Serializer for internal prices for Part model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
quantity = serializers.FloatField()
|
||||||
|
|
||||||
|
price = serializers.CharField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PartInternalPriceBreak
|
||||||
|
fields = [
|
||||||
|
'pk',
|
||||||
|
'part',
|
||||||
|
'quantity',
|
||||||
|
'price',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class PartThumbSerializer(serializers.Serializer):
|
class PartThumbSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
Serializer for the 'image' field of the Part model.
|
Serializer for the 'image' field of the Part model.
|
||||||
|
122
InvenTree/part/templates/part/internal_prices.html
Normal file
122
InvenTree/part/templates/part/internal_prices.html
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
{% extends "part/part_base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
|
||||||
|
{% block menubar %}
|
||||||
|
{% include 'part/navbar.html' with tab='internal-prices' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block heading %}
|
||||||
|
{% trans "Internal Price Information" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block details %}
|
||||||
|
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||||
|
{% if show_internal_price and roles.sales_order.view %}
|
||||||
|
<div id='internal-price-break-toolbar' class='btn-group'>
|
||||||
|
<button class='btn btn-primary' id='new-internal-price-break' type='button'>
|
||||||
|
<span class='fas fa-plus-circle'></span> {% trans "Add Internal Price Break" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class='table table-striped table-condensed' id='internal-price-break-table' data-toolbar='#internal-price-break-toolbar'>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class='container-fluid'>
|
||||||
|
<h3>{% trans "Permission Denied" %}</h3>
|
||||||
|
|
||||||
|
<div class='alert alert-danger alert-block'>
|
||||||
|
{% trans "You do not have permission to view this page." %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block js_ready %}
|
||||||
|
{{ block.super }}
|
||||||
|
|
||||||
|
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||||
|
{% if show_internal_price and roles.sales_order.view %}
|
||||||
|
function reloadPriceBreaks() {
|
||||||
|
$("#internal-price-break-table").bootstrapTable("refresh");
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#new-internal-price-break').click(function() {
|
||||||
|
launchModalForm("{% url 'internal-price-break-create' %}",
|
||||||
|
{
|
||||||
|
success: reloadPriceBreaks,
|
||||||
|
data: {
|
||||||
|
part: {{ part.id }},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#internal-price-break-table').inventreeTable({
|
||||||
|
name: 'internalprice',
|
||||||
|
formatNoMatches: function() { return "{% trans 'No internal price break information found' %}"; },
|
||||||
|
queryParams: {
|
||||||
|
part: {{ part.id }},
|
||||||
|
},
|
||||||
|
url: "{% url 'api-part-internal-price-list' %}",
|
||||||
|
onPostBody: function() {
|
||||||
|
var table = $('#internal-price-break-table');
|
||||||
|
|
||||||
|
table.find('.button-internal-price-break-delete').click(function() {
|
||||||
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
|
launchModalForm(
|
||||||
|
`/part/internal-price/${pk}/delete/`,
|
||||||
|
{
|
||||||
|
success: reloadPriceBreaks
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
table.find('.button-internal-price-break-edit').click(function() {
|
||||||
|
var pk = $(this).attr('pk');
|
||||||
|
|
||||||
|
launchModalForm(
|
||||||
|
`/part/internal-price/${pk}/edit/`,
|
||||||
|
{
|
||||||
|
success: reloadPriceBreaks
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
field: 'pk',
|
||||||
|
title: 'ID',
|
||||||
|
visible: false,
|
||||||
|
switchable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'quantity',
|
||||||
|
title: '{% trans "Quantity" %}',
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'price',
|
||||||
|
title: '{% trans "Price" %}',
|
||||||
|
sortable: true,
|
||||||
|
formatter: function(value, row, index) {
|
||||||
|
var html = value;
|
||||||
|
|
||||||
|
html += `<div class='btn-group float-right' role='group'>`
|
||||||
|
|
||||||
|
html += makeIconButton('fa-edit icon-blue', 'button-internal-price-break-edit', row.pk, '{% trans "Edit internal price break" %}');
|
||||||
|
html += makeIconButton('fa-trash-alt icon-red', 'button-internal-price-break-delete', row.pk, '{% trans "Delete internal price break" %}');
|
||||||
|
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
@ -2,6 +2,8 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
|
|
||||||
|
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||||
|
|
||||||
<ul class='list-group'>
|
<ul class='list-group'>
|
||||||
<li class='list-group-item'>
|
<li class='list-group-item'>
|
||||||
<a href='#' id='part-menu-toggle'>
|
<a href='#' id='part-menu-toggle'>
|
||||||
@ -94,7 +96,13 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if part.salable and roles.sales_order.view %}
|
{% if show_internal_price and roles.sales_order.view %}
|
||||||
|
<li class='list-group-item {% if tab == "internal-prices" %}active{% endif %}' title='{% trans "Internal Price Information" %}'>
|
||||||
|
<a href='{% url "part-internal-prices" part.id %}'>
|
||||||
|
<span class='menu-tab-icon fas fa-dollar-sign' style='width: 20px;'></span>
|
||||||
|
{% trans "Internal Price" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class='list-group-item {% if tab == "sales-prices" %}active{% endif %}' title='{% trans "Sales Price Information" %}'>
|
<li class='list-group-item {% if tab == "sales-prices" %}active{% endif %}' title='{% trans "Sales Price Information" %}'>
|
||||||
<a href='{% url "part-sale-prices" part.id %}'>
|
<a href='{% url "part-sale-prices" part.id %}'>
|
||||||
<span class='menu-tab-icon fas fa-dollar-sign sidebar-icon'></span>
|
<span class='menu-tab-icon fas fa-dollar-sign sidebar-icon'></span>
|
||||||
|
@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
{% block details %}
|
{% block details %}
|
||||||
{% default_currency as currency %}
|
{% default_currency as currency %}
|
||||||
|
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||||
|
|
||||||
<form method="post" class="form-horizontal">
|
<form method="post" class="form-horizontal">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@ -86,6 +87,21 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if show_internal_price and roles.sales_order.view %}
|
||||||
|
{% if total_internal_part_price %}
|
||||||
|
<tr>
|
||||||
|
<td><b>{% trans 'Internal Price' %}</b></td>
|
||||||
|
<td>{% trans 'Unit Cost' %}</td>
|
||||||
|
<td colspan='2'>{% include "price.html" with price=unit_internal_part_price %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td>{% trans 'Total Cost' %}</td>
|
||||||
|
<td colspan='2'>{% include "price.html" with price=total_internal_part_price %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if total_part_price %}
|
{% if total_part_price %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'Sale Price' %}</b></td>
|
<td><b>{% trans 'Sale Price' %}</b></td>
|
||||||
|
@ -3,7 +3,10 @@
|
|||||||
{% load i18n inventree_extras %}
|
{% load i18n inventree_extras %}
|
||||||
|
|
||||||
{% block pre_form_content %}
|
{% block pre_form_content %}
|
||||||
|
|
||||||
{% default_currency as currency %}
|
{% default_currency as currency %}
|
||||||
|
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
|
||||||
|
|
||||||
<table class='table table-striped table-condensed table-price-two'>
|
<table class='table table-striped table-condensed table-price-two'>
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>{% trans 'Part' %}</b></td>
|
<td><b>{% trans 'Part' %}</b></td>
|
||||||
@ -74,6 +77,22 @@
|
|||||||
</table>
|
</table>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if show_internal_price and roles.sales_order.view %}
|
||||||
|
{% if total_internal_part_price %}
|
||||||
|
<h4>{% trans 'Internal Price' %}</h4>
|
||||||
|
<table class='table table-striped table-condensed table-price-two'>
|
||||||
|
<tr>
|
||||||
|
<td><b>{% trans 'Unit Cost' %}</b></td>
|
||||||
|
<td>{% include "price.html" with price=unit_internal_part_price %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>{% trans 'Total Cost' %}</b></td>
|
||||||
|
<td>{% include "price.html" with price=total_internal_part_price %}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if total_part_price %}
|
{% if total_part_price %}
|
||||||
<h4>{% trans 'Sale Price' %}</h4>
|
<h4>{% trans 'Sale Price' %}</h4>
|
||||||
<table class='table table-striped table-condensed table-price-two'>
|
<table class='table table-striped table-condensed table-price-two'>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
import django.core.exceptions as django_exceptions
|
import django.core.exceptions as django_exceptions
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from .models import Part, BomItem
|
from .models import Part, BomItem
|
||||||
|
|
||||||
@ -11,11 +12,16 @@ class BomItemTest(TestCase):
|
|||||||
'part',
|
'part',
|
||||||
'location',
|
'location',
|
||||||
'bom',
|
'bom',
|
||||||
|
'company',
|
||||||
|
'supplier_part',
|
||||||
|
'part_pricebreaks',
|
||||||
|
'price_breaks',
|
||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.bob = Part.objects.get(id=100)
|
self.bob = Part.objects.get(id=100)
|
||||||
self.orphan = Part.objects.get(name='Orphan')
|
self.orphan = Part.objects.get(name='Orphan')
|
||||||
|
self.r1 = Part.objects.get(name='R_2K2_0805')
|
||||||
|
|
||||||
def test_str(self):
|
def test_str(self):
|
||||||
b = BomItem.objects.get(id=1)
|
b = BomItem.objects.get(id=1)
|
||||||
@ -111,3 +117,10 @@ class BomItemTest(TestCase):
|
|||||||
item.validate_hash()
|
item.validate_hash()
|
||||||
|
|
||||||
self.assertNotEqual(h1, h2)
|
self.assertNotEqual(h1, h2)
|
||||||
|
|
||||||
|
def test_pricing(self):
|
||||||
|
self.bob.get_price(1)
|
||||||
|
self.assertEqual(self.bob.get_bom_price_range(1, internal=True), (Decimal(84.5), Decimal(89.5)))
|
||||||
|
# remove internal price for R_2K2_0805
|
||||||
|
self.r1.internal_price_breaks.delete()
|
||||||
|
self.assertEqual(self.bob.get_bom_price_range(1, internal=True), (Decimal(82.5), Decimal(87.5)))
|
||||||
|
@ -51,6 +51,7 @@ class PartTest(TestCase):
|
|||||||
'category',
|
'category',
|
||||||
'part',
|
'part',
|
||||||
'location',
|
'location',
|
||||||
|
'part_pricebreaks'
|
||||||
]
|
]
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@ -113,6 +114,22 @@ class PartTest(TestCase):
|
|||||||
|
|
||||||
self.assertTrue(len(matches) > 0)
|
self.assertTrue(len(matches) > 0)
|
||||||
|
|
||||||
|
def test_sell_pricing(self):
|
||||||
|
# check that the sell pricebreaks were loaded
|
||||||
|
self.assertTrue(self.r1.has_price_breaks)
|
||||||
|
self.assertEqual(self.r1.price_breaks.count(), 2)
|
||||||
|
# check that the sell pricebreaks work
|
||||||
|
self.assertEqual(float(self.r1.get_price(1)), 0.15)
|
||||||
|
self.assertEqual(float(self.r1.get_price(10)), 1.0)
|
||||||
|
|
||||||
|
def test_internal_pricing(self):
|
||||||
|
# check that the sell pricebreaks were loaded
|
||||||
|
self.assertTrue(self.r1.has_internal_price_breaks)
|
||||||
|
self.assertEqual(self.r1.internal_price_breaks.count(), 2)
|
||||||
|
# check that the sell pricebreaks work
|
||||||
|
self.assertEqual(float(self.r1.get_internal_price(1)), 0.08)
|
||||||
|
self.assertEqual(float(self.r1.get_internal_price(10)), 0.5)
|
||||||
|
|
||||||
|
|
||||||
class TestTemplateTest(TestCase):
|
class TestTemplateTest(TestCase):
|
||||||
|
|
||||||
|
@ -29,6 +29,12 @@ sale_price_break_urls = [
|
|||||||
url(r'^(?P<pk>\d+)/delete/', views.PartSalePriceBreakDelete.as_view(), name='sale-price-break-delete'),
|
url(r'^(?P<pk>\d+)/delete/', views.PartSalePriceBreakDelete.as_view(), name='sale-price-break-delete'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
internal_price_break_urls = [
|
||||||
|
url(r'^new/', views.PartInternalPriceBreakCreate.as_view(), name='internal-price-break-create'),
|
||||||
|
url(r'^(?P<pk>\d+)/edit/', views.PartInternalPriceBreakEdit.as_view(), name='internal-price-break-edit'),
|
||||||
|
url(r'^(?P<pk>\d+)/delete/', views.PartInternalPriceBreakDelete.as_view(), name='internal-price-break-delete'),
|
||||||
|
]
|
||||||
|
|
||||||
part_parameter_urls = [
|
part_parameter_urls = [
|
||||||
url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'),
|
url(r'^template/new/', views.PartParameterTemplateCreate.as_view(), name='part-param-template-create'),
|
||||||
url(r'^template/(?P<pk>\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'),
|
url(r'^template/(?P<pk>\d+)/edit/', views.PartParameterTemplateEdit.as_view(), name='part-param-template-edit'),
|
||||||
@ -65,6 +71,7 @@ part_detail_urls = [
|
|||||||
url(r'^orders/?', views.PartDetail.as_view(template_name='part/orders.html'), name='part-orders'),
|
url(r'^orders/?', views.PartDetail.as_view(template_name='part/orders.html'), name='part-orders'),
|
||||||
url(r'^sales-orders/', views.PartDetail.as_view(template_name='part/sales_orders.html'), name='part-sales-orders'),
|
url(r'^sales-orders/', views.PartDetail.as_view(template_name='part/sales_orders.html'), name='part-sales-orders'),
|
||||||
url(r'^sale-prices/', views.PartDetail.as_view(template_name='part/sale_prices.html'), name='part-sale-prices'),
|
url(r'^sale-prices/', views.PartDetail.as_view(template_name='part/sale_prices.html'), name='part-sale-prices'),
|
||||||
|
url(r'^internal-prices/', views.PartDetail.as_view(template_name='part/internal_prices.html'), name='part-internal-prices'),
|
||||||
url(r'^tests/', views.PartDetail.as_view(template_name='part/part_tests.html'), name='part-test-templates'),
|
url(r'^tests/', views.PartDetail.as_view(template_name='part/part_tests.html'), name='part-test-templates'),
|
||||||
url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'),
|
url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'),
|
||||||
url(r'^related-parts/?', views.PartDetail.as_view(template_name='part/related.html'), name='part-related'),
|
url(r'^related-parts/?', views.PartDetail.as_view(template_name='part/related.html'), name='part-related'),
|
||||||
@ -145,6 +152,9 @@ part_urls = [
|
|||||||
# Part price breaks
|
# Part price breaks
|
||||||
url(r'^sale-price/', include(sale_price_break_urls)),
|
url(r'^sale-price/', include(sale_price_break_urls)),
|
||||||
|
|
||||||
|
# Part internal price breaks
|
||||||
|
url(r'^internal-price/', include(internal_price_break_urls)),
|
||||||
|
|
||||||
# Part test templates
|
# Part test templates
|
||||||
url(r'^test-template/', include([
|
url(r'^test-template/', include([
|
||||||
url(r'^new/', views.PartTestTemplateCreate.as_view(), name='part-test-template-create'),
|
url(r'^new/', views.PartTestTemplateCreate.as_view(), name='part-test-template-create'),
|
||||||
|
@ -36,7 +36,7 @@ from .models import PartCategoryParameterTemplate
|
|||||||
from .models import BomItem
|
from .models import BomItem
|
||||||
from .models import match_part_names
|
from .models import match_part_names
|
||||||
from .models import PartTestTemplate
|
from .models import PartTestTemplate
|
||||||
from .models import PartSellPriceBreak
|
from .models import PartSellPriceBreak, PartInternalPriceBreak
|
||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from company.models import SupplierPart
|
from company.models import SupplierPart
|
||||||
@ -2114,7 +2114,8 @@ class PartPricing(AjaxView):
|
|||||||
# BOM pricing information
|
# BOM pricing information
|
||||||
if part.bom_count > 0:
|
if part.bom_count > 0:
|
||||||
|
|
||||||
bom_price = part.get_bom_price_range(quantity)
|
use_internal = InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
|
||||||
|
bom_price = part.get_bom_price_range(quantity, internal=use_internal)
|
||||||
|
|
||||||
if bom_price is not None:
|
if bom_price is not None:
|
||||||
min_bom_price, max_bom_price = bom_price
|
min_bom_price, max_bom_price = bom_price
|
||||||
@ -2136,6 +2137,12 @@ class PartPricing(AjaxView):
|
|||||||
ctx['max_total_bom_price'] = max_bom_price
|
ctx['max_total_bom_price'] = max_bom_price
|
||||||
ctx['max_unit_bom_price'] = max_unit_bom_price
|
ctx['max_unit_bom_price'] = max_unit_bom_price
|
||||||
|
|
||||||
|
# internal part pricing information
|
||||||
|
internal_part_price = part.get_internal_price(quantity)
|
||||||
|
if internal_part_price is not None:
|
||||||
|
ctx['total_internal_part_price'] = round(internal_part_price, 3)
|
||||||
|
ctx['unit_internal_part_price'] = round(internal_part_price / quantity, 3)
|
||||||
|
|
||||||
# part pricing information
|
# part pricing information
|
||||||
part_price = part.get_price(quantity)
|
part_price = part.get_price(quantity)
|
||||||
if part_price is not None:
|
if part_price is not None:
|
||||||
@ -2803,3 +2810,29 @@ class PartSalePriceBreakDelete(AjaxDeleteView):
|
|||||||
model = PartSellPriceBreak
|
model = PartSellPriceBreak
|
||||||
ajax_form_title = _("Delete Price Break")
|
ajax_form_title = _("Delete Price Break")
|
||||||
ajax_template_name = "modal_delete_form.html"
|
ajax_template_name = "modal_delete_form.html"
|
||||||
|
|
||||||
|
|
||||||
|
class PartInternalPriceBreakCreate(PartSalePriceBreakCreate):
|
||||||
|
""" View for creating a internal price break for a part """
|
||||||
|
|
||||||
|
model = PartInternalPriceBreak
|
||||||
|
form_class = part_forms.EditPartInternalPriceBreakForm
|
||||||
|
ajax_form_title = _('Add Internal Price Break')
|
||||||
|
permission_required = 'roles.sales_order.add'
|
||||||
|
|
||||||
|
|
||||||
|
class PartInternalPriceBreakEdit(PartSalePriceBreakEdit):
|
||||||
|
""" View for editing a internal price break """
|
||||||
|
|
||||||
|
model = PartInternalPriceBreak
|
||||||
|
form_class = part_forms.EditPartInternalPriceBreakForm
|
||||||
|
ajax_form_title = _('Edit Internal Price Break')
|
||||||
|
permission_required = 'roles.sales_order.change'
|
||||||
|
|
||||||
|
|
||||||
|
class PartInternalPriceBreakDelete(PartSalePriceBreakDelete):
|
||||||
|
""" View for deleting a internal price break """
|
||||||
|
|
||||||
|
model = PartInternalPriceBreak
|
||||||
|
ajax_form_title = _("Delete Internal Price Break")
|
||||||
|
permission_required = 'roles.sales_order.delete'
|
||||||
|
@ -34,6 +34,9 @@
|
|||||||
{% include "InvenTree/settings/setting.html" with key="PART_COPY_PARAMETERS" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_COPY_PARAMETERS" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_COPY_TESTS" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_COPY_TESTS" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_CATEGORY_PARAMETERS" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_CATEGORY_PARAMETERS" %}
|
||||||
|
<tr><td colspan='5'></td></tr>
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="PART_INTERNAL_PRICE" %}
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="PART_BOM_USE_INTERNAL_PRICE" %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
@ -77,6 +77,7 @@ class RuleSet(models.Model):
|
|||||||
'part_bomitem',
|
'part_bomitem',
|
||||||
'part_partattachment',
|
'part_partattachment',
|
||||||
'part_partsellpricebreak',
|
'part_partsellpricebreak',
|
||||||
|
'part_partinternalpricebreak',
|
||||||
'part_parttesttemplate',
|
'part_parttesttemplate',
|
||||||
'part_partparametertemplate',
|
'part_partparametertemplate',
|
||||||
'part_partparameter',
|
'part_partparameter',
|
||||||
|
Loading…
Reference in New Issue
Block a user