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,
|
||||
},
|
||||
|
||||
'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)'),
|
||||
@ -726,7 +740,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)
|
||||
@ -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
|
||||
"""
|
||||
|
||||
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:
|
||||
@ -756,7 +773,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
|
||||
|
@ -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)
|
||||
|
@ -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'),
|
||||
|
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 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',
|
||||
]
|
||||
|
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)
|
||||
|
||||
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.
|
||||
|
||||
|
@ -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.
|
||||
|
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 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>
|
||||
|
@ -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>
|
||||
|
@ -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'>
|
||||
|
@ -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)))
|
||||
|
@ -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):
|
||||
|
||||
|
@ -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'),
|
||||
|
@ -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
|
||||
@ -2114,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
|
||||
@ -2136,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:
|
||||
@ -2803,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'
|
||||
|
@ -34,6 +34,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>
|
||||
|
||||
|
@ -77,6 +77,7 @@ class RuleSet(models.Model):
|
||||
'part_bomitem',
|
||||
'part_partattachment',
|
||||
'part_partsellpricebreak',
|
||||
'part_partinternalpricebreak',
|
||||
'part_parttesttemplate',
|
||||
'part_partparametertemplate',
|
||||
'part_partparameter',
|
||||
|
Loading…
Reference in New Issue
Block a user