Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver 2021-06-18 11:32:28 +10:00
commit b505b5913b
43 changed files with 2961 additions and 2268 deletions

View File

@ -26,10 +26,9 @@ def canAppAccessDatabase():
'flush',
'loaddata',
'dumpdata',
'makemirations',
'makemigrations',
'migrate',
'check',
'mediarestore',
'shell',
'createsuperuser',
'wait_for_db',

View File

@ -97,7 +97,7 @@ DOCKER = _is_true(get_setting(
# Configure logging settings
log_level = get_setting(
'INVENTREE_LOG_LEVEL',
CONFIG.get('log_level', 'DEBUG')
CONFIG.get('log_level', 'WARNING')
)
logging.basicConfig(

View File

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

View File

@ -52,3 +52,10 @@
part: 2
supplier: 2
SKU: 'ZERGM312'
- model: company.supplierpart
pk: 5
fields:
part: 4
supplier: 2
SKU: 'R_4K7_0603'

View File

@ -65,7 +65,7 @@ class CompanySimpleTest(TestCase):
self.assertEqual(acme.supplied_part_count, 4)
self.assertTrue(appel.has_parts)
self.assertEqual(appel.supplied_part_count, 3)
self.assertEqual(appel.supplied_part_count, 4)
self.assertTrue(zerg.has_parts)
self.assertEqual(zerg.supplied_part_count, 2)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -68,6 +68,7 @@
order: 1
part: 1
quantity: 100
destination: 5 # Desk/Drawer_1
# 250 x ACME0002 (M2x4 LPHS)
# Partially received (50)
@ -95,3 +96,10 @@
part: 3
quantity: 100
# 1 x R_4K7_0603
- model: order.purchaseorderlineitem
pk: 23
fields:
order: 1
part: 5
quantity: 1

View File

@ -79,12 +79,17 @@ class ShipSalesOrderForm(HelperForm):
class ReceivePurchaseOrderForm(HelperForm):
location = TreeNodeChoiceField(queryset=StockLocation.objects.all(), required=True, label=_('Location'), help_text=_('Receive parts to this location'))
location = TreeNodeChoiceField(
queryset=StockLocation.objects.all(),
required=True,
label=_("Destination"),
help_text=_("Receive parts to this location"),
)
class Meta:
model = PurchaseOrder
fields = [
'location',
"location",
]
@ -195,6 +200,7 @@ class EditPurchaseOrderLineItemForm(HelperForm):
'quantity',
'reference',
'purchase_price',
'destination',
'notes',
]

View File

@ -0,0 +1,29 @@
# Generated by Django 3.2 on 2021-05-13 22:38
from django.db import migrations
import django.db.models.deletion
import mptt.fields
class Migration(migrations.Migration):
dependencies = [
("stock", "0063_auto_20210511_2343"),
("order", "0045_auto_20210504_1946"),
]
operations = [
migrations.AddField(
model_name="purchaseorderlineitem",
name="destination",
field=mptt.fields.TreeForeignKey(
blank=True,
help_text="Where does the Purchaser want this item to be stored?",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="po_lines",
to="stock.stocklocation",
verbose_name="Destination",
),
),
]

View File

@ -20,6 +20,7 @@ from django.utils.translation import ugettext_lazy as _
from common.settings import currency_code_default
from markdownx.models import MarkdownxField
from mptt.models import TreeForeignKey
from djmoney.models.fields import MoneyField
@ -672,6 +673,29 @@ class PurchaseOrderLineItem(OrderLineItem):
help_text=_('Unit purchase price'),
)
destination = TreeForeignKey(
'stock.StockLocation', on_delete=models.DO_NOTHING,
verbose_name=_('Destination'),
related_name='po_lines',
blank=True, null=True,
help_text=_('Where does the Purchaser want this item to be stored?')
)
def get_destination(self):
"""Show where the line item is or should be placed"""
# NOTE: If a line item gets split when recieved, only an arbitrary
# stock items location will be reported as the location for the
# entire line.
for stock in stock_models.StockItem.objects.filter(
supplier_part=self.part, purchase_order=self.order
):
if stock.location:
return stock.location
if self.destination:
return self.destination
if self.part and self.part.part and self.part.part.default_location:
return self.part.part.default_location
def remaining(self):
""" Calculate the number of items remaining to be received """
r = self.quantity - self.received

View File

@ -17,6 +17,7 @@ from InvenTree.serializers import InvenTreeAttachmentSerializerField
from company.serializers import CompanyBriefSerializer, SupplierPartSerializer
from part.serializers import PartBriefSerializer
from stock.serializers import LocationBriefSerializer
from .models import PurchaseOrder, PurchaseOrderLineItem
from .models import PurchaseOrderAttachment, SalesOrderAttachment
@ -116,6 +117,8 @@ class POLineItemSerializer(InvenTreeModelSerializer):
purchase_price_string = serializers.CharField(source='purchase_price', read_only=True)
destination = LocationBriefSerializer(source='get_destination', read_only=True)
class Meta:
model = PurchaseOrderLineItem
@ -132,6 +135,7 @@ class POLineItemSerializer(InvenTreeModelSerializer):
'purchase_price',
'purchase_price_currency',
'purchase_price_string',
'destination',
]

View File

@ -117,6 +117,7 @@ $("#po-table").inventreeTable({
part_detail: true,
},
url: "{% url 'api-po-line-list' %}",
showFooter: true,
columns: [
{
field: 'pk',
@ -137,6 +138,9 @@ $("#po-table").inventreeTable({
return '-';
}
},
footerFormatter: function() {
return '{% trans "Total" %}'
}
},
{
field: 'part_detail.description',
@ -172,7 +176,14 @@ $("#po-table").inventreeTable({
{
sortable: true,
field: 'quantity',
title: '{% trans "Quantity" %}'
title: '{% trans "Quantity" %}',
footerFormatter: function(data) {
return data.map(function (row) {
return +row['quantity']
}).reduce(function (sum, i) {
return sum + i
}, 0)
}
},
{
sortable: true,
@ -182,6 +193,25 @@ $("#po-table").inventreeTable({
return row.purchase_price_string || row.purchase_price;
}
},
{
sortable: true,
title: '{% trans "Total price" %}',
formatter: function(value, row) {
var total = row.purchase_price * row.quantity;
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: row.purchase_price_currency});
return formatter.format(total)
},
footerFormatter: function(data) {
var total = data.map(function (row) {
return +row['purchase_price']*row['quantity']
}).reduce(function (sum, i) {
return sum + i
}, 0)
var currency = (data.slice(-1)[0] && data.slice(-1)[0].purchase_price_currency) || 'USD';
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: currency});
return formatter.format(total)
}
},
{
sortable: true,
field: 'received',
@ -204,6 +234,10 @@ $("#po-table").inventreeTable({
return (progressA < progressB) ? 1 : -1;
}
},
{
field: 'destination.pathstring',
title: '{% trans "Destination" %}',
},
{
field: 'notes',
title: '{% trans "Notes" %}',

View File

@ -22,6 +22,7 @@
<th>{% trans "Received" %}</th>
<th>{% trans "Receive" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Destination" %}</th>
<th></th>
</tr>
{% for line in lines %}
@ -53,6 +54,9 @@
</select>
</div>
</td>
<td>
{{ line.get_destination }}
</td>
<td>
<button class='btn btn-default btn-remove' onClick="removeOrderRowFromOrderWizard()" id='del_item_{{ line.id }}' title='{% trans "Remove line" %}' type='button'>
<span row='line_row_{{ line.id }}' class='fas fa-times-circle icon-red'></span>

View File

@ -199,6 +199,7 @@ $("#so-lines-table").inventreeTable({
detailFormatter: showFulfilledSubTable,
{% endif %}
{% endif %}
showFooter: true,
columns: [
{
field: 'pk',
@ -217,7 +218,10 @@ $("#so-lines-table").inventreeTable({
} else {
return '-';
}
}
},
footerFormatter: function() {
return '{% trans "Total" %}'
},
},
{
sortable: true,
@ -228,6 +232,13 @@ $("#so-lines-table").inventreeTable({
sortable: true,
field: 'quantity',
title: '{% trans "Quantity" %}',
footerFormatter: function(data) {
return data.map(function (row) {
return +row['quantity']
}).reduce(function (sum, i) {
return sum + i
}, 0)
},
},
{
sortable: true,
@ -237,6 +248,26 @@ $("#so-lines-table").inventreeTable({
return row.sale_price_string || row.sale_price;
}
},
{
sortable: true,
title: '{% trans "Total price" %}',
formatter: function(value, row) {
var total = row.sale_price * row.quantity;
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: row.sale_price_currency});
return formatter.format(total)
},
footerFormatter: function(data) {
var total = data.map(function (row) {
return +row['sale_price']*row['quantity']
}).reduce(function (sum, i) {
return sum + i
}, 0)
var currency = (data.slice(-1)[0] && data.slice(-1)[0].sale_price_currency) || 'USD';
var formatter = new Intl.NumberFormat('en-US', {style: 'currency', currency: currency});
return formatter.format(total)
}
},
{
field: 'allocated',
{% if order.status == SalesOrderStatus.PENDING %}

View File

@ -87,7 +87,7 @@ class OrderTest(TestCase):
order = PurchaseOrder.objects.get(pk=1)
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
self.assertEqual(order.lines.count(), 3)
self.assertEqual(order.lines.count(), 4)
sku = SupplierPart.objects.get(SKU='ACME-WIDGET')
part = sku.part
@ -105,11 +105,11 @@ class OrderTest(TestCase):
order.add_line_item(sku, 100)
self.assertEqual(part.on_order, 100)
self.assertEqual(order.lines.count(), 4)
self.assertEqual(order.lines.count(), 5)
# Order the same part again (it should be merged)
order.add_line_item(sku, 50)
self.assertEqual(order.lines.count(), 4)
self.assertEqual(order.lines.count(), 5)
self.assertEqual(part.on_order, 150)
# Try to order a supplier part from the wrong supplier
@ -163,7 +163,7 @@ class OrderTest(TestCase):
loc = StockLocation.objects.get(id=1)
# There should be two lines against this order
self.assertEqual(len(order.pending_line_items()), 3)
self.assertEqual(len(order.pending_line_items()), 4)
# Should fail, as order is 'PENDING' not 'PLACED"
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)

View File

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

View File

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

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

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

View File

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

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

View File

@ -14,8 +14,18 @@
{% block details %}
{% default_currency as currency %}
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% crispy form %}
<form method="post" class="form-horizontal">
{% csrf_token %}
<div class="row">
<div class="col-sm-9">{{ form|crispy }}</div>
<div class="col-sm-3">
<input type="submit" value="{% trans 'Calculate' %}" class="btn btn-primary btn-block">
</div>
</div>
</form>
<hr>
<div class="row"><div class="col col-md-6">
<h4>{% trans "Pricing ranges" %}</h4>
@ -77,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>
@ -110,8 +135,8 @@
{% if price_history %}
<hr>
<h4>{% trans 'Stock Pricing' %}<i class="fas fa-info-circle" title="Shows the prices of stock for this part
the part single price shown is the current price for that supplier part"></i></h4>
<h4>{% trans 'Stock Pricing' %}<i class="fas fa-info-circle" title="Shows the purchase prices of stock for this part.
The part single price is the current purchase price for that supplier part."></i></h4>
{% if price_history|length > 1 %}
<div style="max-width: 99%; min-height: 300px">
<canvas id="StockPriceChart"></canvas>
@ -157,7 +182,8 @@ the part single price shown is the current price for that supplier part"></i></h
{% for line in price_history %}{{ line.price_diff|stringformat:".2f" }},{% endfor %}
],
borderWidth: 1,
type: 'line'
type: 'line',
hidden: true,
},
{
label: '{% blocktrans %}Part Single Price - {{currency}}{% endblocktrans %}',
@ -168,7 +194,8 @@ the part single price shown is the current price for that supplier part"></i></h
{% for line in price_history %}{{ line.price_part|stringformat:".2f" }},{% endfor %}
],
borderWidth: 1,
type: 'line'
type: 'line',
hidden: true,
},
{% endif %}
{
@ -187,18 +214,18 @@ the part single price shown is the current price for that supplier part"></i></h
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)

View File

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

View File

@ -208,6 +208,29 @@ def get_color_theme_css(username):
return inventree_css_static_url
@register.filter
def keyvalue(dict, key):
"""
access to key of supplied dict
usage:
{% mydict|keyvalue:mykey %}
"""
return dict[key]
@register.simple_tag()
def call_method(obj, method_name, *args):
"""
enables calling model methods / functions from templates with arguments
usage:
{% call_method model_object 'fnc_name' argument1 %}
"""
method = getattr(obj, method_name)
return method(*args)
@register.simple_tag()
def authorized_owners(group):
""" Return authorized owners """

View File

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

View File

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

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

View File

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

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

View File

@ -776,7 +776,8 @@ function handleModalForm(url, options) {
// Form was returned, invalid!
else {
if (!options.hideErrorMessage) {
// Disable error message with option or response
if (!options.hideErrorMessage && !response.hideErrorMessage) {
var warningDiv = $(modal).find('#form-validation-warning');
warningDiv.css('display', 'block');
}
@ -791,6 +792,19 @@ function handleModalForm(url, options) {
if (options.secondary) {
attachSecondaries(modal, options.secondary);
}
// Set modal title with response
if (response.title) {
modalSetTitle(modal, response.title);
}
// Clean custom action buttons
$(modal).find('#modal-footer-buttons').html('');
// Add custom action buttons with response
if (response.buttons) {
attachButtons(modal, response.buttons);
}
}
else {
$(modal).modal('hide');
@ -837,6 +851,7 @@ function launchModalForm(url, options = {}) {
* secondary - List of secondary modals to attach
* callback - List of callback functions to attach to inputs
* focus - Select which field to focus on by default
* buttons - additional buttons that should be added as array with [name, title]
*/
var modal = options.modal || '#modal-form';
@ -896,6 +911,11 @@ function launchModalForm(url, options = {}) {
attachButtons(modal, options.buttons);
}
// Add custom buttons from response
if (response.buttons) {
attachButtons(modal, response.buttons);
}
} else {
$(modal).modal('hide');
showAlertDialog('{% trans "Invalid server response" %}', '{% trans "JSON response missing form data" %}');

View File

@ -77,6 +77,7 @@ class RuleSet(models.Model):
'part_bomitem',
'part_partattachment',
'part_partsellpricebreak',
'part_partinternalpricebreak',
'part_parttesttemplate',
'part_partparametertemplate',
'part_partparameter',