mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-20 13:56:30 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into part-import
This commit is contained in:
@ -7,7 +7,7 @@ from __future__ import unicode_literals
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django.http import JsonResponse
|
||||
from django.db.models import Q, F, Count
|
||||
from django.db.models import Q, F, Count, Min, Max, Avg
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from rest_framework import status
|
||||
@ -15,6 +15,10 @@ from rest_framework.response import Response
|
||||
from rest_framework import filters, serializers
|
||||
from rest_framework import generics
|
||||
|
||||
from djmoney.money import Money
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
|
||||
from django.conf.urls import url, include
|
||||
from django.urls import reverse
|
||||
|
||||
@ -24,6 +28,7 @@ from .models import PartAttachment, PartTestTemplate
|
||||
from .models import PartSellPriceBreak
|
||||
from .models import PartCategoryParameterTemplate
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from build.models import Build
|
||||
|
||||
from . import serializers as part_serializers
|
||||
@ -816,6 +821,14 @@ class BomList(generics.ListCreateAPIView):
|
||||
|
||||
queryset = queryset.filter(inherited=inherited)
|
||||
|
||||
# Filter by "allow_variants"
|
||||
variants = params.get("allow_variants", None)
|
||||
|
||||
if variants is not None:
|
||||
variants = str2bool(variants)
|
||||
|
||||
queryset = queryset.filter(allow_variants=variants)
|
||||
|
||||
# Filter by part?
|
||||
part = params.get('part', None)
|
||||
|
||||
@ -877,6 +890,60 @@ class BomList(generics.ListCreateAPIView):
|
||||
else:
|
||||
queryset = queryset.exclude(pk__in=pks)
|
||||
|
||||
# Annotate with purchase prices
|
||||
queryset = queryset.annotate(
|
||||
purchase_price_min=Min('sub_part__stock_items__purchase_price'),
|
||||
purchase_price_max=Max('sub_part__stock_items__purchase_price'),
|
||||
purchase_price_avg=Avg('sub_part__stock_items__purchase_price'),
|
||||
)
|
||||
|
||||
# Get values for currencies
|
||||
currencies = queryset.annotate(
|
||||
purchase_price_currency=F('sub_part__stock_items__purchase_price_currency'),
|
||||
).values('pk', 'sub_part', 'purchase_price_currency')
|
||||
|
||||
def convert_price(price, currency, decimal_places=4):
|
||||
""" Convert price field, returns Money field """
|
||||
|
||||
price_adjusted = None
|
||||
|
||||
# Get default currency from settings
|
||||
default_currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
|
||||
|
||||
if price:
|
||||
if currency and default_currency:
|
||||
try:
|
||||
# Get adjusted price
|
||||
price_adjusted = convert_money(Money(price, currency), default_currency)
|
||||
except MissingRate:
|
||||
# No conversion rate set
|
||||
price_adjusted = Money(price, currency)
|
||||
else:
|
||||
# Currency exists
|
||||
if currency:
|
||||
price_adjusted = Money(price, currency)
|
||||
# Default currency exists
|
||||
if default_currency:
|
||||
price_adjusted = Money(price, default_currency)
|
||||
|
||||
if price_adjusted and decimal_places:
|
||||
price_adjusted.decimal_places = decimal_places
|
||||
|
||||
return price_adjusted
|
||||
|
||||
# Convert prices to default currency (using backend conversion rates)
|
||||
for bom_item in queryset:
|
||||
# Find associated currency (select first found)
|
||||
purchase_price_currency = None
|
||||
for currency_item in currencies:
|
||||
if currency_item['pk'] == bom_item.pk and currency_item['sub_part'] == bom_item.sub_part.pk:
|
||||
purchase_price_currency = currency_item['purchase_price_currency']
|
||||
break
|
||||
# Convert prices
|
||||
bom_item.purchase_price_min = convert_price(bom_item.purchase_price_min, purchase_price_currency)
|
||||
bom_item.purchase_price_max = convert_price(bom_item.purchase_price_max, purchase_price_currency)
|
||||
bom_item.purchase_price_avg = convert_price(bom_item.purchase_price_avg, purchase_price_currency)
|
||||
|
||||
return queryset
|
||||
|
||||
filter_backends = [
|
||||
|
@ -352,6 +352,7 @@ class EditBomItemForm(HelperForm):
|
||||
'reference',
|
||||
'overage',
|
||||
'note',
|
||||
'allow_variants',
|
||||
'inherited',
|
||||
'optional',
|
||||
]
|
||||
|
18
InvenTree/part/migrations/0066_bomitem_allow_variants.py
Normal file
18
InvenTree/part/migrations/0066_bomitem_allow_variants.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2 on 2021-06-01 03:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('part', '0065_auto_20210505_2144'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bomitem',
|
||||
name='allow_variants',
|
||||
field=models.BooleanField(default=False, help_text='Stock items for variant parts can be used for this BOM item', verbose_name='Allow Variants'),
|
||||
),
|
||||
]
|
@ -1861,6 +1861,59 @@ class Part(MPTTModel):
|
||||
|
||||
return self.get_descendants(include_self=False)
|
||||
|
||||
@property
|
||||
def can_convert(self):
|
||||
"""
|
||||
Check if this Part can be "converted" to a different variant:
|
||||
|
||||
It can be converted if:
|
||||
|
||||
a) It has non-virtual variant parts underneath it
|
||||
b) It has non-virtual template parts above it
|
||||
c) It has non-virtual sibling variants
|
||||
|
||||
"""
|
||||
|
||||
return self.get_conversion_options().count() > 0
|
||||
|
||||
def get_conversion_options(self):
|
||||
"""
|
||||
Return options for converting this part to a "variant" within the same tree
|
||||
|
||||
a) Variants underneath this one
|
||||
b) Immediate parent
|
||||
c) Siblings
|
||||
"""
|
||||
|
||||
parts = []
|
||||
|
||||
# Child parts
|
||||
children = self.get_descendants(include_self=False)
|
||||
|
||||
for child in children:
|
||||
parts.append(child)
|
||||
|
||||
# Immediate parent
|
||||
if self.variant_of:
|
||||
parts.append(self.variant_of)
|
||||
|
||||
siblings = self.get_siblings(include_self=False)
|
||||
|
||||
for sib in siblings:
|
||||
parts.append(sib)
|
||||
|
||||
filtered_parts = Part.objects.filter(pk__in=[part.pk for part in parts])
|
||||
|
||||
# Ensure this part is not in the queryset, somehow
|
||||
filtered_parts = filtered_parts.exclude(pk=self.pk)
|
||||
|
||||
filtered_parts = filtered_parts.filter(
|
||||
active=True,
|
||||
virtual=False,
|
||||
)
|
||||
|
||||
return filtered_parts
|
||||
|
||||
def get_related_parts(self):
|
||||
""" Return list of tuples for all related parts:
|
||||
- first value is PartRelated object
|
||||
@ -2187,6 +2240,7 @@ class BomItem(models.Model):
|
||||
note: Note field for this BOM item
|
||||
checksum: Validation checksum for the particular BOM line item
|
||||
inherited: This BomItem can be inherited by the BOMs of variant parts
|
||||
allow_variants: Stock for part variants can be substituted for this BomItem
|
||||
"""
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@ -2235,6 +2289,12 @@ class BomItem(models.Model):
|
||||
help_text=_('This BOM item is inherited by BOMs for variant parts'),
|
||||
)
|
||||
|
||||
allow_variants = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Allow Variants'),
|
||||
help_text=_('Stock items for variant parts can be used for this BOM item')
|
||||
)
|
||||
|
||||
def get_item_hash(self):
|
||||
""" Calculate the checksum hash of this BOM line item:
|
||||
|
||||
|
@ -12,6 +12,7 @@ from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
|
||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
||||
from rest_framework import serializers
|
||||
from sql_util.utils import SubqueryCount, SubquerySum
|
||||
from djmoney.contrib.django_rest_framework import MoneyField
|
||||
from stock.models import StockItem
|
||||
|
||||
from .models import (BomItem, Part, PartAttachment, PartCategory,
|
||||
@ -367,6 +368,14 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
|
||||
validated = serializers.BooleanField(read_only=True, source='is_line_valid')
|
||||
|
||||
purchase_price_min = MoneyField(max_digits=10, decimal_places=6, read_only=True)
|
||||
|
||||
purchase_price_max = MoneyField(max_digits=10, decimal_places=6, read_only=True)
|
||||
|
||||
purchase_price_avg = serializers.SerializerMethodField()
|
||||
|
||||
purchase_price_range = serializers.SerializerMethodField()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# part_detail and sub_part_detail serializers are only included if requested.
|
||||
# This saves a bunch of database requests
|
||||
@ -394,9 +403,57 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks')
|
||||
return queryset
|
||||
|
||||
def get_purchase_price_range(self, obj):
|
||||
""" Return purchase price range """
|
||||
|
||||
try:
|
||||
purchase_price_min = obj.purchase_price_min
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
try:
|
||||
purchase_price_max = obj.purchase_price_max
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
if purchase_price_min and not purchase_price_max:
|
||||
# Get price range
|
||||
purchase_price_range = str(purchase_price_max)
|
||||
elif not purchase_price_min and purchase_price_max:
|
||||
# Get price range
|
||||
purchase_price_range = str(purchase_price_max)
|
||||
elif purchase_price_min and purchase_price_max:
|
||||
# Get price range
|
||||
if purchase_price_min >= purchase_price_max:
|
||||
# If min > max: use min only
|
||||
purchase_price_range = str(purchase_price_min)
|
||||
else:
|
||||
purchase_price_range = str(purchase_price_min) + " - " + str(purchase_price_max)
|
||||
else:
|
||||
purchase_price_range = '-'
|
||||
|
||||
return purchase_price_range
|
||||
|
||||
def get_purchase_price_avg(self, obj):
|
||||
""" Return purchase price average """
|
||||
|
||||
try:
|
||||
purchase_price_avg = obj.purchase_price_avg
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
if purchase_price_avg:
|
||||
# Get string representation of price average
|
||||
purchase_price_avg = str(purchase_price_avg)
|
||||
else:
|
||||
purchase_price_avg = '-'
|
||||
|
||||
return purchase_price_avg
|
||||
|
||||
class Meta:
|
||||
model = BomItem
|
||||
fields = [
|
||||
'allow_variants',
|
||||
'inherited',
|
||||
'note',
|
||||
'optional',
|
||||
@ -404,6 +461,10 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
'pk',
|
||||
'part',
|
||||
'part_detail',
|
||||
'purchase_price_avg',
|
||||
'purchase_price_max',
|
||||
'purchase_price_min',
|
||||
'purchase_price_range',
|
||||
'quantity',
|
||||
'reference',
|
||||
'sub_part',
|
||||
|
@ -58,7 +58,7 @@ def part_salable_default():
|
||||
|
||||
def part_trackable_default():
|
||||
"""
|
||||
Returns the defualt value fro the 'trackable' field for a Part object
|
||||
Returns the default value for the 'trackable' field for a Part object
|
||||
"""
|
||||
|
||||
return InvenTreeSetting.get_setting('PART_TRACKABLE')
|
||||
|
@ -137,6 +137,13 @@
|
||||
<li><a href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Buttons to toggle between grid and table view -->
|
||||
<button id='view-list' class='btn btn-default' type='button' title='{% trans "View list display" %}'>
|
||||
<span class='fas fa-th-list'></span>
|
||||
</button>
|
||||
<button id='view-grid' class='btn btn-default' type='button' title='{% trans "View grid display" %}'>
|
||||
<span class='fas fa-th'></span>
|
||||
</button>
|
||||
<div class='filter-list' id='filter-list-parts'>
|
||||
<!-- Empty div -->
|
||||
</div>
|
||||
@ -147,7 +154,7 @@
|
||||
<div class='panel-heading'>
|
||||
<h4>
|
||||
{% block heading %}
|
||||
{% trans "Part Categories" %}
|
||||
{% trans "Parts" %}
|
||||
{% endblock %}
|
||||
</h4>
|
||||
</div>
|
||||
@ -178,6 +185,22 @@
|
||||
toggleId: '#category-menu-toggle',
|
||||
});
|
||||
|
||||
$('#view-list').click(function() {
|
||||
$('#view-list').hide();
|
||||
$('#view-grid').show();
|
||||
|
||||
$('#part-table').bootstrapTable('toggleCustomView');
|
||||
inventreeSave('part-grid-view', '');
|
||||
});
|
||||
|
||||
$('#view-grid').click(function() {
|
||||
$('#view-grid').hide();
|
||||
$('#view-list').show();
|
||||
|
||||
$('#part-table').bootstrapTable('toggleCustomView');
|
||||
inventreeSave('part-grid-view', 1);
|
||||
});
|
||||
|
||||
$("#cat-create").click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'category-create' %}",
|
||||
@ -282,7 +305,15 @@
|
||||
},
|
||||
buttons: ['#part-options'],
|
||||
checkbox: true,
|
||||
gridView: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (inventreeLoad("part-grid-view")) {
|
||||
$('#view-grid').hide();
|
||||
$('#part-table').bootstrapTable('toggleCustomView');
|
||||
} else {
|
||||
$('#view-list').hide();
|
||||
}
|
||||
|
||||
{% endblock %}
|
@ -69,6 +69,12 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if part.purchaseable and roles.purchase_order.view %}
|
||||
<li class='list-group-item {% if tab == "order-prices" %}active{% endif %}' title='{% trans "Order Price Information" %}'>
|
||||
<a href='{% url "part-order-prices" part.id %}'>
|
||||
<span class='menu-tab-icon fas fa-dollar-sign' style='width: 20px;'></span>
|
||||
{% trans "Order Price" %}
|
||||
</a>
|
||||
</li>
|
||||
<li class='list-group-item {% if tab == "manufacturers" %}active{% endif %}' title='{% trans "Manufacturers" %}'>
|
||||
<a href='{% url "part-manufacturers" part.id %}'>
|
||||
<span class='menu-tab-icon fas fa-industry'></span>
|
||||
|
208
InvenTree/part/templates/part/order_prices.html
Normal file
208
InvenTree/part/templates/part/order_prices.html
Normal file
@ -0,0 +1,208 @@
|
||||
{% extends "part/part_base.html" %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block menubar %}
|
||||
{% include 'part/navbar.html' with tab='order-prices' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Order Price Information" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block details %}
|
||||
{% settings_value "INVENTREE_DEFAULT_CURRENCY" as currency %}
|
||||
|
||||
{% crispy form %}
|
||||
|
||||
<div class="row"><div class="col col-md-6">
|
||||
<h4>{% trans "Pricing ranges" %}</h4>
|
||||
<table class='table table-striped table-condensed'>
|
||||
{% if part.supplier_count > 0 %}
|
||||
{% if min_total_buy_price %}
|
||||
<tr>
|
||||
<td><b>{% trans 'Supplier Pricing' %}</b></td>
|
||||
<td>{% trans 'Unit Cost' %}</td>
|
||||
<td>Min: {% include "price.html" with price=min_unit_buy_price %}</td>
|
||||
<td>Max: {% include "price.html" with price=max_unit_buy_price %}</td>
|
||||
</tr>
|
||||
{% if quantity > 1 %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>{% trans 'Total Cost' %}</td>
|
||||
<td>Min: {% include "price.html" with price=min_total_buy_price %}</td>
|
||||
<td>Max: {% include "price.html" with price=max_total_buy_price %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan='4'>
|
||||
<span class='warning-msg'><i>{% trans 'No supplier pricing available' %}</i></span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if part.bom_count > 0 %}
|
||||
{% if min_total_bom_price %}
|
||||
<tr>
|
||||
<td><b>{% trans 'BOM Pricing' %}</b></td>
|
||||
<td>{% trans 'Unit Cost' %}</td>
|
||||
<td>Min: {% include "price.html" with price=min_unit_bom_price %}</td>
|
||||
<td>Max: {% include "price.html" with price=max_unit_bom_price %}</td>
|
||||
</tr>
|
||||
{% if quantity > 1 %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>{% trans 'Total Cost' %}</td>
|
||||
<td>Min: {% include "price.html" with price=min_total_bom_price %}</td>
|
||||
<td>Max: {% include "price.html" with price=max_total_bom_price %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if part.has_complete_bom_pricing == False %}
|
||||
<tr>
|
||||
<td colspan='4'>
|
||||
<span class='warning-msg'><i>{% trans 'Note: BOM pricing is incomplete for this part' %}</i></span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan='4'>
|
||||
<span class='warning-msg'><i>{% trans 'No BOM pricing available' %}</i></span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if total_part_price %}
|
||||
<tr>
|
||||
<td><b>{% trans 'Sale Price' %}</b></td>
|
||||
<td>{% trans 'Unit Cost' %}</td>
|
||||
<td colspan='2'>{% include "price.html" with price=unit_part_price %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>{% trans 'Total Cost' %}</td>
|
||||
<td colspan='2'>{% include "price.html" with price=total_part_price %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
{% if min_unit_buy_price or min_unit_bom_price %}
|
||||
{% else %}
|
||||
<div class='alert alert-danger alert-block'>
|
||||
{% trans 'No pricing information is available for this part.' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if part.bom_count > 0 %}
|
||||
<div class="col col-md-6">
|
||||
<h4>{% trans 'BOM Pricing' %}</h4>
|
||||
<div style="max-width: 99%;">
|
||||
<canvas id="BomChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% 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>
|
||||
{% if price_history|length > 1 %}
|
||||
<div style="max-width: 99%; min-height: 300px">
|
||||
<canvas id="StockPriceChart"></canvas>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class='alert alert-danger alert-block'>
|
||||
{% trans 'No stock pricing history is available for this part.' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
{% settings_value "INVENTREE_DEFAULT_CURRENCY" as currency %}
|
||||
{% if price_history %}
|
||||
var pricedata = {
|
||||
labels: [
|
||||
{% for line in price_history %}'{{ line.date }}',{% endfor %}
|
||||
],
|
||||
datasets: [{
|
||||
label: '{% blocktrans %}Single Price - {{currency}}{% endblocktrans %}',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.2)',
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
yAxisID: 'y',
|
||||
data: [
|
||||
{% for line in price_history %}{{ line.price|stringformat:".2f" }},{% endfor %}
|
||||
],
|
||||
borderWidth: 1,
|
||||
type: 'line'
|
||||
},
|
||||
{% if 'price_diff' in price_history.0 %}
|
||||
{
|
||||
label: '{% blocktrans %}Single Price Difference - {{currency}}{% endblocktrans %}',
|
||||
backgroundColor: 'rgba(68, 157, 68, 0.2)',
|
||||
borderColor: 'rgb(68, 157, 68)',
|
||||
yAxisID: 'y2',
|
||||
data: [
|
||||
{% for line in price_history %}{{ line.price_diff|stringformat:".2f" }},{% endfor %}
|
||||
],
|
||||
borderWidth: 1,
|
||||
type: 'line'
|
||||
},
|
||||
{
|
||||
label: '{% blocktrans %}Part Single Price - {{currency}}{% endblocktrans %}',
|
||||
backgroundColor: 'rgba(70, 127, 155, 0.2)',
|
||||
borderColor: 'rgb(70, 127, 155)',
|
||||
yAxisID: 'y',
|
||||
data: [
|
||||
{% for line in price_history %}{{ line.price_part|stringformat:".2f" }},{% endfor %}
|
||||
],
|
||||
borderWidth: 1,
|
||||
type: 'line'
|
||||
},
|
||||
{% endif %}
|
||||
{
|
||||
label: '{% trans "Quantity" %}',
|
||||
backgroundColor: 'rgba(255, 206, 86, 0.2)',
|
||||
borderColor: 'rgb(255, 206, 86)',
|
||||
yAxisID: 'y1',
|
||||
data: [
|
||||
{% for line in price_history %}{{ line.qty|stringformat:"f" }},{% endfor %}
|
||||
],
|
||||
borderWidth: 1
|
||||
}]
|
||||
}
|
||||
var StockPriceChart = loadStockPricingChart(document.getElementById('StockPriceChart'), pricedata)
|
||||
var bom_colors = randomColor({hue: 'green', count: {{ bom_parts|length }} })
|
||||
var bomdata = {
|
||||
labels: [{% for line in bom_parts %}'{{ line.name }}',{% endfor %}],
|
||||
datasets: [
|
||||
{% if bom_pie_min %}
|
||||
{
|
||||
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)
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
@ -102,6 +102,11 @@
|
||||
</div>
|
||||
|
||||
<div class='info-messages'>
|
||||
{% if part.virtual %}
|
||||
<div class='alert alert-warning alert-block'>
|
||||
{% trans "This is a virtual part" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if part.variant_of %}
|
||||
<div class='alert alert-info alert-block'>
|
||||
{% object_link 'part-variants' part.variant_of.id part.variant_of.full_name as link %}
|
||||
|
@ -3,7 +3,6 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
|
||||
<table class='table table-striped table-condensed table-price-two'>
|
||||
<tr>
|
||||
<td><b>{% trans 'Part' %}</b></td>
|
||||
@ -95,4 +94,4 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<hr>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
@ -59,6 +59,7 @@ part_detail_urls = [
|
||||
url(r'^bom/?', views.PartDetail.as_view(template_name='part/bom.html'), name='part-bom'),
|
||||
url(r'^build/?', views.PartDetail.as_view(template_name='part/build.html'), name='part-build'),
|
||||
url(r'^used/?', views.PartDetail.as_view(template_name='part/used_in.html'), name='part-used-in'),
|
||||
url(r'^order-prices/', views.PartPricingView.as_view(template_name='part/order_prices.html'), name='part-order-prices'),
|
||||
url(r'^manufacturers/?', views.PartDetail.as_view(template_name='part/manufacturer.html'), name='part-manufacturers'),
|
||||
url(r'^suppliers/?', views.PartDetail.as_view(template_name='part/supplier.html'), name='part-suppliers'),
|
||||
url(r'^orders/?', views.PartDetail.as_view(template_name='part/orders.html'), name='part-orders'),
|
||||
|
@ -20,6 +20,7 @@ from django.conf import settings
|
||||
from django.contrib import messages
|
||||
|
||||
from moneyed import CURRENCIES
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
|
||||
from PIL import Image
|
||||
|
||||
@ -949,6 +950,94 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
|
||||
return context
|
||||
|
||||
|
||||
class PartPricingView(PartDetail):
|
||||
""" Detail view for Part object
|
||||
"""
|
||||
context_object_name = 'part'
|
||||
template_name = 'part/order_prices.html'
|
||||
form_class = part_forms.PartPriceForm
|
||||
|
||||
# Add in some extra context information based on query params
|
||||
def get_context_data(self, **kwargs):
|
||||
""" Provide extra context data to template """
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
ctx = self.get_pricing(self.get_quantity())
|
||||
ctx['form'] = self.form_class(initial=self.get_initials())
|
||||
|
||||
context.update(ctx)
|
||||
return context
|
||||
|
||||
def get_quantity(self):
|
||||
""" Return set quantity in decimal format """
|
||||
return Decimal(self.request.POST.get('quantity', 1))
|
||||
|
||||
def get_part(self):
|
||||
return self.get_object()
|
||||
|
||||
def get_pricing(self, quantity=1, currency=None):
|
||||
""" returns context with pricing information """
|
||||
ctx = PartPricing.get_pricing(self, quantity, currency)
|
||||
part = self.get_part()
|
||||
# Stock history
|
||||
if part.total_stock > 1:
|
||||
ret = []
|
||||
stock = part.stock_entries(include_variants=False, in_stock=True) # .order_by('purchase_order__date')
|
||||
stock = stock.prefetch_related('purchase_order', 'supplier_part')
|
||||
|
||||
for stock_item in stock:
|
||||
if None in [stock_item.purchase_price, stock_item.quantity]:
|
||||
continue
|
||||
|
||||
# convert purchase price to current currency - only one currency in the graph
|
||||
price = convert_money(stock_item.purchase_price, inventree_settings.currency_code_default())
|
||||
line = {
|
||||
'price': price.amount,
|
||||
'qty': stock_item.quantity
|
||||
}
|
||||
# Supplier Part Name # TODO use in graph
|
||||
if stock_item.supplier_part:
|
||||
line['name'] = stock_item.supplier_part.pretty_name
|
||||
|
||||
if stock_item.supplier_part.unit_pricing and price:
|
||||
line['price_diff'] = price.amount - stock_item.supplier_part.unit_pricing
|
||||
line['price_part'] = stock_item.supplier_part.unit_pricing
|
||||
|
||||
# set date for graph labels
|
||||
if stock_item.purchase_order:
|
||||
line['date'] = stock_item.purchase_order.issue_date.strftime('%d.%m.%Y')
|
||||
else:
|
||||
line['date'] = stock_item.tracking_info.first().date.strftime('%d.%m.%Y')
|
||||
ret.append(line)
|
||||
|
||||
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]
|
||||
|
||||
return ctx
|
||||
|
||||
def get_initials(self):
|
||||
""" returns initials for form """
|
||||
return {'quantity': self.get_quantity()}
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
kwargs['object'] = self.object
|
||||
ctx = self.get_context_data(**kwargs)
|
||||
return self.get(request, context=ctx)
|
||||
|
||||
|
||||
class PartDetailFromIPN(PartDetail):
|
||||
slug_field = 'IPN'
|
||||
slug_url_kwarg = 'slug'
|
||||
@ -2842,7 +2931,7 @@ class PartSalePriceBreakCreate(AjaxCreateView):
|
||||
|
||||
initials['part'] = self.get_part()
|
||||
|
||||
default_currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
|
||||
default_currency = settings.BASE_CURRENCY
|
||||
currency = CURRENCIES.get(default_currency, None)
|
||||
|
||||
if currency is not None:
|
||||
|
Reference in New Issue
Block a user