Merge pull request #1634 from matmair/internal-price

Internal price
This commit is contained in:
Oliver 2021-06-18 10:36:29 +10:00 committed by GitHub
commit 43478a0be7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 458 additions and 14 deletions

View File

@ -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

View File

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

View File

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

View 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

View File

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

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

View File

@ -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.

View File

@ -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.

View 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 %}

View File

@ -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>

View File

@ -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>

View File

@ -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'>

View File

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

View File

@ -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):

View File

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

View File

@ -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'

View File

@ -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>

View File

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