diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 9d92583e1a..b78fe1c2bf 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -212,6 +212,20 @@ class InvenTreeSetting(models.Model): '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': { 'name': _('Debug Mode'), 'description': _('Generate reports in debug mode (HTML output)'), @@ -733,7 +747,7 @@ class PriceBreak(models.Model): 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. - Don't forget to add in flat-fee cost (base_cost field) @@ -741,7 +755,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 """ - 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? if len(price_breaks) == 0: @@ -763,7 +780,7 @@ def get_price(instance, quantity, moq=True, multiples=True, currency=None): currency = currency_code_default() pb_min = None - for pb in instance.price_breaks.all(): + for pb in price_breaks: # Store smallest price break if not pb_min: pb_min = pb diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index 3945b56d7c..637dbbf2cf 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -14,7 +14,7 @@ from .models import BomItem from .models import PartParameterTemplate, PartParameter from .models import PartCategoryParameterTemplate from .models import PartTestTemplate -from .models import PartSellPriceBreak +from .models import PartSellPriceBreak, PartInternalPriceBreak from stock.models import StockLocation from company.models import SupplierPart @@ -286,6 +286,14 @@ class PartSellPriceBreakAdmin(admin.ModelAdmin): 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(PartCategory, PartCategoryAdmin) admin.site.register(PartRelated, PartRelatedAdmin) @@ -297,3 +305,4 @@ admin.site.register(PartParameter, ParameterAdmin) admin.site.register(PartCategoryParameterTemplate, PartCategoryParameterAdmin) admin.site.register(PartTestTemplate, PartTestTemplateAdmin) admin.site.register(PartSellPriceBreak, PartSellPriceBreakAdmin) +admin.site.register(PartInternalPriceBreak, PartInternalPriceBreakAdmin) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 537b0f9e40..c2785b666f 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -25,7 +25,7 @@ from django.urls import reverse from .models import Part, PartCategory, BomItem from .models import PartParameter, PartParameterTemplate from .models import PartAttachment, PartTestTemplate -from .models import PartSellPriceBreak +from .models import PartSellPriceBreak, PartInternalPriceBreak from .models import PartCategoryParameterTemplate 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): """ 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'), ])), + # 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 url(r'^parameter/', include([ url(r'^template/$', PartParameterTemplateList.as_view(), name='api-part-param-template-list'), diff --git a/InvenTree/part/fixtures/part_pricebreaks.yaml b/InvenTree/part/fixtures/part_pricebreaks.yaml new file mode 100644 index 0000000000..0fd14c84df --- /dev/null +++ b/InvenTree/part/fixtures/part_pricebreaks.yaml @@ -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 diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 95de4961f9..ec799bcf8d 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -20,7 +20,7 @@ from .models import BomItem from .models import PartParameterTemplate, PartParameter from .models import PartCategoryParameterTemplate from .models import PartTestTemplate -from .models import PartSellPriceBreak +from .models import PartSellPriceBreak, PartInternalPriceBreak class PartModelChoiceField(forms.ModelChoiceField): @@ -394,3 +394,19 @@ class EditPartSalePriceBreakForm(HelperForm): 'quantity', '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', + ] diff --git a/InvenTree/part/migrations/0067_partinternalpricebreak.py b/InvenTree/part/migrations/0067_partinternalpricebreak.py new file mode 100644 index 0000000000..f1b16fe87c --- /dev/null +++ b/InvenTree/part/migrations/0067_partinternalpricebreak.py @@ -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')}, + }, + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 7b9038fecb..6c05d62a7e 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1544,7 +1544,7 @@ class Part(MPTTModel): 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. Adds the minimum price for all components in the BOM. @@ -1561,7 +1561,7 @@ class Part(MPTTModel): print("Warning: Item contains itself in BOM") 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: continue @@ -1585,7 +1585,7 @@ class Part(MPTTModel): 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: @@ -1596,8 +1596,13 @@ class Part(MPTTModel): 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 - 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: return bom_price_range @@ -1649,6 +1654,22 @@ class Part(MPTTModel): 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 def copy_bom_from(self, other, clear=True, **kwargs): """ @@ -1983,6 +2004,21 @@ class PartSellPriceBreak(common.models.PriceBreak): 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): """ A PartStar object creates a relationship between a User and a Part. diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index d03a37c6dc..76275bd5e1 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -17,7 +17,8 @@ from stock.models import StockItem from .models import (BomItem, Part, PartAttachment, PartCategory, PartParameter, PartParameterTemplate, PartSellPriceBreak, - PartStar, PartTestTemplate, PartCategoryParameterTemplate) + PartStar, PartTestTemplate, PartCategoryParameterTemplate, + PartInternalPriceBreak) 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): """ Serializer for the 'image' field of the Part model. diff --git a/InvenTree/part/templates/part/internal_prices.html b/InvenTree/part/templates/part/internal_prices.html new file mode 100644 index 0000000000..2f54f3bb64 --- /dev/null +++ b/InvenTree/part/templates/part/internal_prices.html @@ -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 %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/navbar.html b/InvenTree/part/templates/part/navbar.html index d1ed7e3d21..c0bc4c96a3 100644 --- a/InvenTree/part/templates/part/navbar.html +++ b/InvenTree/part/templates/part/navbar.html @@ -2,6 +2,8 @@ {% load static %} {% load inventree_extras %} +{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %} + <ul class='list-group'> <li class='list-group-item'> <a href='#' id='part-menu-toggle'> @@ -94,7 +96,13 @@ </a> </li> {% 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" %}'> <a href='{% url "part-sale-prices" part.id %}'> <span class='menu-tab-icon fas fa-dollar-sign sidebar-icon'></span> diff --git a/InvenTree/part/templates/part/order_prices.html b/InvenTree/part/templates/part/order_prices.html index f5af15afef..a9da632a33 100644 --- a/InvenTree/part/templates/part/order_prices.html +++ b/InvenTree/part/templates/part/order_prices.html @@ -14,6 +14,7 @@ {% block details %} {% default_currency as currency %} +{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %} <form method="post" class="form-horizontal"> {% csrf_token %} @@ -86,6 +87,21 @@ {% 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 %} <tr> <td><b>{% trans 'Sale Price' %}</b></td> @@ -198,18 +214,18 @@ The part single price is the current purchase price for that supplier part."></i var bomdata = { labels: [{% for line in bom_parts %}'{{ line.name }}',{% endfor %}], datasets: [ - {% if bom_pie_min %} + { + label: 'Price', + data: [{% for line in bom_parts %}{{ line.min_price }},{% endfor %}], + backgroundColor: bom_colors, + }, + {% if bom_pie_max %} { label: 'Max Price', data: [{% for line in bom_parts %}{{ line.max_price }},{% endfor %}], backgroundColor: bom_colors, }, {% endif %} - { - label: 'Price', - data: [{% for line in bom_parts %}{% if bom_pie_min %}{{ line.min_price }}{% else %}{{ line.price }}{% endif%},{% endfor %}], - backgroundColor: bom_colors, - } ] }; var BomChart = loadBomChart(document.getElementById('BomChart'), bomdata) diff --git a/InvenTree/part/templates/part/part_pricing.html b/InvenTree/part/templates/part/part_pricing.html index e035a77162..ce55124bd9 100644 --- a/InvenTree/part/templates/part/part_pricing.html +++ b/InvenTree/part/templates/part/part_pricing.html @@ -3,7 +3,10 @@ {% load i18n inventree_extras %} {% block pre_form_content %} + {% default_currency as currency %} +{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %} + <table class='table table-striped table-condensed table-price-two'> <tr> <td><b>{% trans 'Part' %}</b></td> @@ -74,6 +77,22 @@ </table> {% 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 %} <h4>{% trans 'Sale Price' %}</h4> <table class='table table-striped table-condensed table-price-two'> diff --git a/InvenTree/part/test_bom_item.py b/InvenTree/part/test_bom_item.py index 7e553be73a..66897b28fc 100644 --- a/InvenTree/part/test_bom_item.py +++ b/InvenTree/part/test_bom_item.py @@ -1,5 +1,6 @@ from django.test import TestCase import django.core.exceptions as django_exceptions +from decimal import Decimal from .models import Part, BomItem @@ -11,11 +12,16 @@ class BomItemTest(TestCase): 'part', 'location', 'bom', + 'company', + 'supplier_part', + 'part_pricebreaks', + 'price_breaks', ] def setUp(self): self.bob = Part.objects.get(id=100) self.orphan = Part.objects.get(name='Orphan') + self.r1 = Part.objects.get(name='R_2K2_0805') def test_str(self): b = BomItem.objects.get(id=1) @@ -111,3 +117,10 @@ class BomItemTest(TestCase): item.validate_hash() 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))) diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index cd8726ccf4..2bc24c3a99 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -51,6 +51,7 @@ class PartTest(TestCase): 'category', 'part', 'location', + 'part_pricebreaks' ] def setUp(self): @@ -113,6 +114,22 @@ class PartTest(TestCase): 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): diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index e53ce54782..d12612c604 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -29,6 +29,12 @@ sale_price_break_urls = [ 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 = [ 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'), @@ -65,6 +71,7 @@ part_detail_urls = [ 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'^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'^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'), @@ -145,6 +152,9 @@ part_urls = [ # Part price breaks 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 url(r'^test-template/', include([ url(r'^new/', views.PartTestTemplateCreate.as_view(), name='part-test-template-create'), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index b4f33c9382..b68c889516 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -36,7 +36,7 @@ from .models import PartCategoryParameterTemplate from .models import BomItem from .models import match_part_names from .models import PartTestTemplate -from .models import PartSellPriceBreak +from .models import PartSellPriceBreak, PartInternalPriceBreak from common.models import InvenTreeSetting from company.models import SupplierPart @@ -846,17 +846,26 @@ class PartPricingView(PartDetail): ctx['price_history'] = ret # BOM Information for Pie-Chart - bom_items = [{'name': str(a.sub_part), 'price': a.sub_part.get_price_range(quantity), 'q': a.quantity} for a in part.bom_items.all()] - if [True for a in bom_items if len(set(a['price'])) == 2]: - ctx['bom_parts'] = [{ - 'name': a['name'], - 'min_price': str((a['price'][0] * a['q']) / quantity), - 'max_price': str((a['price'][1] * a['q']) / quantity)} for a in bom_items] - ctx['bom_pie_min'] = True - else: - ctx['bom_parts'] = [{ - 'name': a['name'], - 'price': str((a['price'][0] * a['q']) / quantity)} for a in bom_items] + if part.has_bom: + ctx_bom_parts = [] + # iterate over all bom-items + for item in part.bom_items.all(): + ctx_item = {'name': str(item.sub_part)} + price, qty = item.sub_part.get_price_range(quantity), item.quantity + + price_min, price_max = 0, 0 + if price: # check if price available + price_min = str((price[0] * qty) / quantity) + if len(set(price)) == 2: # min and max-price present + price_max = str((price[1] * qty) / quantity) + ctx['bom_pie_max'] = True # enable showing max prices in bom + + ctx_item['max_price'] = price_min + ctx_item['min_price'] = price_max if price_max else price_min + ctx_bom_parts.append(ctx_item) + + # add to global context + ctx['bom_parts'] = ctx_bom_parts return ctx @@ -2105,7 +2114,8 @@ class PartPricing(AjaxView): # BOM pricing information 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: min_bom_price, max_bom_price = bom_price @@ -2127,6 +2137,12 @@ class PartPricing(AjaxView): ctx['max_total_bom_price'] = max_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_price = part.get_price(quantity) if part_price is not None: @@ -2794,3 +2810,29 @@ class PartSalePriceBreakDelete(AjaxDeleteView): model = PartSellPriceBreak ajax_form_title = _("Delete Price Break") 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' diff --git a/InvenTree/templates/InvenTree/settings/part.html b/InvenTree/templates/InvenTree/settings/part.html index cb4726bdc4..1bf5f8794a 100644 --- a/InvenTree/templates/InvenTree/settings/part.html +++ b/InvenTree/templates/InvenTree/settings/part.html @@ -35,6 +35,9 @@ {% 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_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> </table> diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index f26a7ee64b..0902b04ccd 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -77,6 +77,7 @@ class RuleSet(models.Model): 'part_bomitem', 'part_partattachment', 'part_partsellpricebreak', + 'part_partinternalpricebreak', 'part_parttesttemplate', 'part_partparametertemplate', 'part_partparameter',