Merge pull request #1482 from matmair/price-history

Stock price history
This commit is contained in:
Oliver 2021-05-29 17:27:51 +10:00 committed by GitHub
commit 7f58d95254
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 428 additions and 9 deletions

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
!function(r,e){var n;"object"==typeof exports?(n=e(),"object"==typeof module&&module&&module.exports&&(exports=module.exports=n),exports.randomColor=n):"function"==typeof define&&define.amd?define([],e):r.randomColor=e()}(this,function(){var o=null,s={};r("monochrome",null,[[0,0],[100,0]]),r("red",[-26,18],[[20,100],[30,92],[40,89],[50,85],[60,78],[70,70],[80,60],[90,55],[100,50]]),r("orange",[18,46],[[20,100],[30,93],[40,88],[50,86],[60,85],[70,70],[100,70]]),r("yellow",[46,62],[[25,100],[40,94],[50,89],[60,86],[70,84],[80,82],[90,80],[100,75]]),r("green",[62,178],[[30,100],[40,90],[50,85],[60,81],[70,74],[80,64],[90,50],[100,40]]),r("blue",[178,257],[[20,100],[30,86],[40,80],[50,74],[60,60],[70,52],[80,44],[90,39],[100,35]]),r("purple",[257,282],[[20,100],[30,87],[40,79],[50,70],[60,65],[70,59],[80,52],[90,45],[100,42]]),r("pink",[282,334],[[20,100],[30,90],[40,86],[60,84],[80,80],[90,75],[100,73]]);var i=[],f=function(r){if(void 0!==(r=r||{}).seed&&null!==r.seed&&r.seed===parseInt(r.seed,10))o=r.seed;else if("string"==typeof r.seed)o=function(r){for(var e=0,n=0;n!==r.length&&!(e>=Number.MAX_SAFE_INTEGER);n++)e+=r.charCodeAt(n);return e}(r.seed);else{if(void 0!==r.seed&&null!==r.seed)throw new TypeError("The seed value must be an integer or string");o=null}var e,n;if(null===r.count||void 0===r.count)return function(r,e){switch(e.format){case"hsvArray":return r;case"hslArray":return d(r);case"hsl":var n=d(r);return"hsl("+n[0]+", "+n[1]+"%, "+n[2]+"%)";case"hsla":var t=d(r),a=e.alpha||Math.random();return"hsla("+t[0]+", "+t[1]+"%, "+t[2]+"%, "+a+")";case"rgbArray":return h(r);case"rgb":return"rgb("+h(r).join(", ")+")";case"rgba":var u=h(r),a=e.alpha||Math.random();return"rgba("+u.join(", ")+", "+a+")";default:return function(r){var e=h(r);function n(r){var e=r.toString(16);return 1==e.length?"0"+e:e}return"#"+n(e[0])+n(e[1])+n(e[2])}(r)}}([e=function(r){{if(0<i.length){var e=c(o=function(r){if(isNaN(r)){if("string"==typeof r)if(s[r]){var e=s[r];if(e.hueRange)return e.hueRange}else if(r.match(/^#?([0-9A-F]{3}|[0-9A-F]{6})$/i)){return l(g(r)[0]).hueRange}}else{var n=parseInt(r);if(n<360&&0<n)return l(r).hueRange}return[0,360]}(r.hue)),n=(o[1]-o[0])/i.length,t=parseInt((e-o[0])/n);!0===i[t]?t=(t+2)%i.length:i[t]=!0;var a=(o[0]+t*n)%359,u=(o[0]+(t+1)*n)%359;return(e=c(o=[a,u]))<0&&(e=360+e),e}var o=function(r){if("number"==typeof parseInt(r)){var e=parseInt(r);if(e<360&&0<e)return[e,e]}if("string"==typeof r)if(s[r]){var n=s[r];if(n.hueRange)return n.hueRange}else if(r.match(/^#?([0-9A-F]{3}|[0-9A-F]{6})$/i)){var t=g(r)[0];return[t,t]}return[0,360]}(r.hue);return(e=c(o))<0&&(e=360+e),e}}(r),n=function(r,e){if("monochrome"===e.hue)return 0;if("random"===e.luminosity)return c([0,100]);var n=function(r){return l(r).saturationRange}(r),t=n[0],a=n[1];switch(e.luminosity){case"bright":t=55;break;case"dark":t=a-10;break;case"light":a=55}return c([t,a])}(e,r),function(r,e,n){var t=function(r,e){for(var n=l(r).lowerBounds,t=0;t<n.length-1;t++){var a=n[t][0],u=n[t][1],o=n[t+1][0],s=n[t+1][1];if(a<=e&&e<=o){var i=(s-u)/(o-a);return i*e+(u-i*a)}}return 0}(r,e),a=100;switch(n.luminosity){case"dark":a=t+20;break;case"light":t=(a+t)/2;break;case"random":t=0,a=100}return c([t,a])}(e,n,r)],r);for(var t=r.count,a=[],u=0;u<r.count;u++)i.push(!1);for(r.count=null;t>a.length;)o&&r.seed&&(r.seed+=1),a.push(f(r));return r.count=t,a};function l(r){for(var e in 334<=r&&r<=360&&(r-=360),s){var n=s[e];if(n.hueRange&&r>=n.hueRange[0]&&r<=n.hueRange[1])return s[e]}return"Color not found"}function c(r){if(null===o){var e=Math.random();return e+=.618033988749895,e%=1,Math.floor(r[0]+e*(r[1]+1-r[0]))}var n=r[1]||1,t=r[0]||0,a=(o=(9301*o+49297)%233280)/233280;return Math.floor(t+a*(n-t))}function r(r,e,n){var t=n[0][0],a=n[n.length-1][0],u=n[n.length-1][1],o=n[0][1];s[r]={hueRange:e,lowerBounds:n,saturationRange:[t,a],brightnessRange:[u,o]}}function h(r){var e=r[0];0===e&&(e=1),360===e&&(e=359),e/=360;var n=r[1]/100,t=r[2]/100,a=Math.floor(6*e),u=6*e-a,o=t*(1-n),s=t*(1-u*n),i=t*(1-(1-u)*n),f=256,l=256,c=256;switch(a){case 0:f=t,l=i,c=o;break;case 1:f=s,l=t,c=o;break;case 2:f=o,l=t,c=i;break;case 3:f=o,l=s,c=t;break;case 4:f=i,l=o,c=t;break;case 5:f=t,l=o,c=s}return[Math.floor(255*f),Math.floor(255*l),Math.floor(255*c)]}function g(r){r=3===(r=r.replace(/^#/,"")).length?r.replace(/(.)/g,"$1$1"):r;var e=parseInt(r.substr(0,2),16)/255,n=parseInt(r.substr(2,2),16)/255,t=parseInt(r.substr(4,2),16)/255,a=Math.max(e,n,t),u=a-Math.min(e,n,t),o=a?u/a:0;switch(a){case e:return[(n-t)/u%6*60||0,o,a];case n:return[60*((t-e)/u+2)||0,o,a];case t:return[60*((e-n)/u+4)||0,o,a]}}function d(r){var e=r[0],n=r[1]/100,t=r[2]/100,a=(2-n)*t;return[e,Math.round(n*t/(a<1?a:2-a)*1e4)/100,a/2*100]}return f});

View File

@ -50,6 +50,15 @@
supplier: 3
status: 40 # Cancelled
# for pricebreaks
- model: order.purchaseorder
pk: 7
fields:
reference: '0007'
description: 'Another PO'
supplier: 2
status: 10 # Pending
# Add some line items against PO 0001
# 100 x ACME0001 (M2x4 LPHS)

View File

@ -60,18 +60,18 @@ class PurchaseOrderTest(OrderTest):
def test_po_list(self):
# List *ALL* PO items
self.filter({}, 6)
self.filter({}, 7)
# Filter by supplier
self.filter({'supplier': 1}, 1)
self.filter({'supplier': 3}, 5)
# Filter by "outstanding"
self.filter({'outstanding': True}, 4)
self.filter({'outstanding': True}, 5)
self.filter({'outstanding': False}, 2)
# Filter by "status"
self.filter({'status': 10}, 2)
self.filter({'status': 10}, 3)
self.filter({'status': 40}, 1)
def test_overdue(self):
@ -80,14 +80,14 @@ class PurchaseOrderTest(OrderTest):
"""
self.filter({'overdue': True}, 0)
self.filter({'overdue': False}, 6)
self.filter({'overdue': False}, 7)
order = PurchaseOrder.objects.get(pk=1)
order.target_date = datetime.now().date() - timedelta(days=10)
order.save()
self.filter({'overdue': True}, 1)
self.filter({'overdue': False}, 5)
self.filter({'overdue': False}, 6)
def test_po_detail(self):

View File

@ -21,6 +21,7 @@ class OrderTest(TestCase):
fixtures = [
'company',
'supplier_part',
'price_breaks',
'category',
'part',
'location',
@ -63,7 +64,7 @@ class OrderTest(TestCase):
next_ref = PurchaseOrder.getNextOrderNumber()
self.assertEqual(next_ref, '0007')
self.assertEqual(next_ref, '0008')
def test_on_order(self):
""" There should be 3 separate items on order for the M2x4 LPHS part """
@ -117,6 +118,39 @@ class OrderTest(TestCase):
with self.assertRaises(django_exceptions.ValidationError):
order.add_line_item(sku, 99)
def test_pricing(self):
""" Test functions for adding line items to an order including price-breaks """
order = PurchaseOrder.objects.get(pk=7)
self.assertEqual(order.status, PurchaseOrderStatus.PENDING)
self.assertEqual(order.lines.count(), 0)
sku = SupplierPart.objects.get(SKU='ZERGM312')
part = sku.part
# Order the part
self.assertEqual(part.on_order, 0)
# Order 25 with manually set high value
pp = sku.get_price(25)
order.add_line_item(sku, 25, purchase_price=pp)
self.assertEqual(part.on_order, 25)
self.assertEqual(order.lines.count(), 1)
self.assertEqual(order.lines.first().purchase_price.amount, 200)
# Add a few, now the pricebreak should adjust although wrong price given
order.add_line_item(sku, 10, purchase_price=sku.get_price(25))
self.assertEqual(part.on_order, 35)
self.assertEqual(order.lines.count(), 1)
self.assertEqual(order.lines.first().purchase_price.amount, 8)
# Order the same part again (it should be merged)
order.add_line_item(sku, 100, purchase_price=sku.get_price(100))
self.assertEqual(order.lines.count(), 1)
self.assertEqual(part.on_order, 135)
self.assertEqual(order.lines.first().purchase_price.amount, 1.25)
def test_receive(self):
""" Test order receiving functions """

View File

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

View File

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

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

View File

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

View File

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

View File

@ -19,6 +19,7 @@ from django.forms import HiddenInput, CheckboxInput
from django.conf import settings
from moneyed import CURRENCIES
from djmoney.contrib.exchange.models import convert_money
from PIL import Image
@ -782,6 +783,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'

View File

@ -138,7 +138,9 @@
<script type="text/javascript" src="{% static 'fullcalendar/locales-all.js' %}"></script>
<script type="text/javascript" src="{% static 'script/select2/select2.js' %}"></script>
<script type='text/javascript' src="{% static 'script/moment.js' %}"></script>
<script type='text/javascript' src="{% static 'script/chart.min.js' %}"></script>
<script type='text/javascript' src="{% static 'script/clipboard.min.js' %}"></script>
<script type='text/javascript' src="{% static 'script/randomColor.min.js' %}"></script>
<!-- general InvenTree -->
<script type='text/javascript' src="{% static 'script/inventree/inventree.js' %}"></script>

View File

@ -688,3 +688,60 @@ function loadPartTestTemplateTable(table, options) {
]
});
}
function loadStockPricingChart(context, data) {
return new Chart(context, {
type: 'bar',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {legend: {position: 'bottom'}},
scales: {
y: {
type: 'linear',
position: 'left',
grid: {display: false},
title: {
display: true,
text: '{% trans "Single Price" %}'
}
},
y1: {
type: 'linear',
position: 'right',
grid: {display: false},
titel: {
display: true,
text: '{% trans "Quantity" %}',
position: 'right'
}
},
y2: {
type: 'linear',
position: 'left',
grid: {display: false},
title: {
display: true,
text: '{% trans "Single Price Difference" %}'
}
}
},
}
});
}
function loadBomChart(context, data) {
return new Chart(context, {
type: 'doughnut',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {legend: {position: 'bottom'},
scales: {xAxes: [{beginAtZero: true, ticks: {autoSkip: false}}]}}
}
});
}