mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Include stock item purchase price in pricing cache (#4292)
* Add setting to control pricing calculation from stock items * Bug fix for displaying a "null" setting * Add new fields to PartPricing model * Add code for calculation of min/max stock item costs * Update pricing display to use stock item pricing * Add unit testing for new pricing features * Automatically update pricing when stock item is created or edited * Increment API version * Improvements for price rendering * Update based on feedback: - Roll stock item pricing into purchase pricing - Simplify models - Update unit tests
This commit is contained in:
parent
df4209801a
commit
9a289948e5
@ -390,9 +390,12 @@ class BaseInvenTreeSetting(models.Model):
|
||||
|
||||
if create:
|
||||
# Attempt to create a new settings object
|
||||
|
||||
default_value = cls.get_setting_default(key, **kwargs)
|
||||
|
||||
setting = cls(
|
||||
key=key,
|
||||
value=cls.get_setting_default(key, **kwargs),
|
||||
value=default_value,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@ -1173,6 +1176,24 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'PRICING_USE_STOCK_PRICING': {
|
||||
'name': _('Use Stock Item Pricing'),
|
||||
'description': _('Use pricing from manually entered stock data for pricing calculations'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'PRICING_STOCK_ITEM_AGE_DAYS': {
|
||||
'name': _('Stock Item Pricing Age'),
|
||||
'description': _('Exclude stock items older than this number of days from pricing calculations'),
|
||||
'default': 0,
|
||||
'units': 'days',
|
||||
'validator': [
|
||||
int,
|
||||
MinValueValidator(0),
|
||||
]
|
||||
},
|
||||
|
||||
'PRICING_USE_VARIANT_PRICING': {
|
||||
'name': _('Use Variant Pricing'),
|
||||
'description': _('Include variant pricing in overall pricing calculations'),
|
||||
|
@ -195,7 +195,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{% if tp == None %}
|
||||
<span class='badge bg-warning'>{% trans "Total cost could not be calculated" %}</span>
|
||||
{% else %}
|
||||
{{ tp }}
|
||||
{% include "price_data.html" with price=tp %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</td>
|
||||
|
@ -193,7 +193,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{% if tp == None %}
|
||||
<span class='badge bg-warning'>{% trans "Total cost could not be calculated" %}</span>
|
||||
{% else %}
|
||||
{{ tp }}
|
||||
{% include "price_data.html" with price=tp %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</td>
|
||||
|
@ -6,7 +6,7 @@ import decimal
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
@ -2510,6 +2510,31 @@ class PartPricing(common.models.MetaMixin):
|
||||
if purchase_max is None or purchase_cost > purchase_max:
|
||||
purchase_max = purchase_cost
|
||||
|
||||
# Also check if manual stock item pricing is included
|
||||
if InvenTreeSetting.get_setting('PRICING_USE_STOCK_PRICING', True, cache=False):
|
||||
|
||||
items = self.part.stock_items.all()
|
||||
|
||||
# Limit to stock items updated within a certain window
|
||||
days = int(InvenTreeSetting.get_setting('PRICING_STOCK_ITEM_AGE_DAYS', 0, cache=False))
|
||||
|
||||
if days > 0:
|
||||
date_threshold = datetime.now().date() - timedelta(days=days)
|
||||
items = items.filter(updated__gte=date_threshold)
|
||||
|
||||
for item in items:
|
||||
cost = self.convert(item.purchase_price)
|
||||
|
||||
# Skip if the cost could not be converted (for some reason)
|
||||
if cost is None:
|
||||
continue
|
||||
|
||||
if purchase_min is None or cost < purchase_min:
|
||||
purchase_min = cost
|
||||
|
||||
if purchase_max is None or cost > purchase_max:
|
||||
purchase_max = cost
|
||||
|
||||
self.purchase_cost_min = purchase_min
|
||||
self.purchase_cost_max = purchase_max
|
||||
|
||||
@ -2651,6 +2676,7 @@ class PartPricing(common.models.MetaMixin):
|
||||
max_costs.append(self.supplier_price_max)
|
||||
|
||||
if InvenTreeSetting.get_setting('PRICING_USE_VARIANT_PRICING', True, cache=False):
|
||||
# Include variant pricing in overall calculations
|
||||
min_costs.append(self.variant_cost_min)
|
||||
max_costs.append(self.variant_cost_max)
|
||||
|
||||
|
@ -45,9 +45,7 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<th>
|
||||
{% trans "Internal Pricing" %}
|
||||
</th>
|
||||
<th>{% trans "Internal Pricing" %}</th>
|
||||
<td>{% include "price_data.html" with price=pricing.internal_cost_min %}</td>
|
||||
<td>{% include "price_data.html" with price=pricing.internal_cost_max %}</td>
|
||||
</tr>
|
||||
@ -60,9 +58,7 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<th>
|
||||
{% trans "Purchase History" %}
|
||||
</th>
|
||||
<th>{% trans "Purchase History" %}</th>
|
||||
<td>{% include "price_data.html" with price=pricing.purchase_cost_min %}</td>
|
||||
<td>{% include "price_data.html" with price=pricing.purchase_cost_max %}</td>
|
||||
</tr>
|
||||
@ -74,9 +70,7 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<th>
|
||||
{% trans "Supplier Pricing" %}
|
||||
</th>
|
||||
<th>{% trans "Supplier Pricing" %}</th>
|
||||
<td>{% include "price_data.html" with price=pricing.supplier_price_min %}</td>
|
||||
<td>{% include "price_data.html" with price=pricing.supplier_price_max %}</td>
|
||||
</tr>
|
||||
@ -90,9 +84,7 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<th>
|
||||
{% trans "BOM Pricing" %}
|
||||
</th>
|
||||
<th>{% trans "BOM Pricing" %}</th>
|
||||
<td>{% include "price_data.html" with price=pricing.bom_cost_min %}</td>
|
||||
<td>{% include "price_data.html" with price=pricing.bom_cost_max %}</td>
|
||||
</tr>
|
||||
@ -107,9 +99,7 @@
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<th>
|
||||
{% trans "Overall Pricing" %}
|
||||
</th>
|
||||
<th>{% trans "Overall Pricing" %}</th>
|
||||
<th>{% include "price_data.html" with price=pricing.overall_min %}</th>
|
||||
<th>{% include "price_data.html" with price=pricing.overall_max %}</th>
|
||||
</tr>
|
||||
@ -135,15 +125,9 @@
|
||||
<span class='fas fa-dollar-sign'></span>
|
||||
</a>
|
||||
</td>
|
||||
<th>
|
||||
{% trans "Sale Price" %}
|
||||
</th>
|
||||
<td>
|
||||
{% include "price_data.html" with price=pricing.sale_price_min %}
|
||||
</td>
|
||||
<td>
|
||||
{% include "price_data.html" with price=pricing.sale_price_max %}
|
||||
</td>
|
||||
<th>{% trans "Sale Price" %}</th>
|
||||
<td>{% include "price_data.html" with price=pricing.sale_price_min %}</td>
|
||||
<td>{% include "price_data.html" with price=pricing.sale_price_max %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
@ -151,15 +135,9 @@
|
||||
<span class='fas fa-chart-line'></span>
|
||||
</a>
|
||||
</td>
|
||||
<th>
|
||||
{% trans "Sale History" %}
|
||||
</th>
|
||||
<td>
|
||||
{% include "price_data.html" with price=pricing.sale_history_min %}
|
||||
</td>
|
||||
<td>
|
||||
{% include "price_data.html" with price=pricing.sale_history_max %}
|
||||
</td>
|
||||
<th>{% trans "Sale History" %}</th>
|
||||
<td>{% include "price_data.html" with price=pricing.sale_history_min %}</td>
|
||||
<td>{% include "price_data.html" with price=pricing.sale_history_max %}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
@ -10,6 +10,7 @@ import common.settings
|
||||
import company.models
|
||||
import order.models
|
||||
import part.models
|
||||
import stock.models
|
||||
from InvenTree.helpers import InvenTreeTestCase
|
||||
from InvenTree.status_codes import PurchaseOrderStatus
|
||||
|
||||
@ -234,6 +235,54 @@ class PartPricingTests(InvenTreeTestCase):
|
||||
self.assertEqual(pricing.internal_cost_max, Money(10, currency))
|
||||
self.assertEqual(pricing.overall_max, Money(10, currency))
|
||||
|
||||
def test_stock_item_pricing(self):
|
||||
"""Test for stock item pricing data"""
|
||||
|
||||
# Create a part
|
||||
p = part.models.Part.objects.create(
|
||||
name='Test part for pricing',
|
||||
description='hello world',
|
||||
)
|
||||
|
||||
# Create some stock items
|
||||
prices = [
|
||||
(10, 'AUD'),
|
||||
(5, 'USD'),
|
||||
(2, 'CAD'),
|
||||
]
|
||||
|
||||
for price, currency in prices:
|
||||
|
||||
stock.models.StockItem.objects.create(
|
||||
part=p,
|
||||
quantity=10,
|
||||
purchase_price=price,
|
||||
purchase_price_currency=currency
|
||||
)
|
||||
|
||||
# Ensure that initially, stock item pricing is disabled
|
||||
common.models.InvenTreeSetting.set_setting('PRICING_USE_STOCK_PRICING', False, None)
|
||||
|
||||
pricing = p.pricing
|
||||
pricing.update_pricing()
|
||||
|
||||
# Check that stock item pricing data is not used
|
||||
self.assertIsNone(pricing.purchase_cost_min)
|
||||
self.assertIsNone(pricing.purchase_cost_max)
|
||||
self.assertIsNone(pricing.overall_min)
|
||||
self.assertIsNone(pricing.overall_max)
|
||||
|
||||
# Turn on stock pricing
|
||||
common.models.InvenTreeSetting.set_setting('PRICING_USE_STOCK_PRICING', True, None)
|
||||
|
||||
pricing.update_pricing()
|
||||
|
||||
self.assertIsNotNone(pricing.purchase_cost_min)
|
||||
self.assertIsNotNone(pricing.purchase_cost_max)
|
||||
|
||||
self.assertEqual(pricing.overall_min, Money(1.176471, 'USD'))
|
||||
self.assertEqual(pricing.overall_max, Money(6.666667, 'USD'))
|
||||
|
||||
def test_bom_pricing(self):
|
||||
"""Unit test for BOM pricing calculations"""
|
||||
|
||||
|
@ -1991,6 +1991,10 @@ def after_delete_stock_item(sender, instance: StockItem, **kwargs):
|
||||
# Run this check in the background
|
||||
InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance.part)
|
||||
|
||||
# Schedule an update on parent part pricing
|
||||
if InvenTree.ready.canAppAccessDatabase():
|
||||
instance.part.schedule_pricing_update()
|
||||
|
||||
|
||||
@receiver(post_save, sender=StockItem, dispatch_uid='stock_item_post_save_log')
|
||||
def after_save_stock_item(sender, instance: StockItem, created, **kwargs):
|
||||
@ -2001,6 +2005,9 @@ def after_save_stock_item(sender, instance: StockItem, created, **kwargs):
|
||||
# Run this check in the background
|
||||
InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance.part)
|
||||
|
||||
if InvenTree.ready.canAppAccessDatabase():
|
||||
instance.part.schedule_pricing_update()
|
||||
|
||||
|
||||
class StockItemAttachment(InvenTreeAttachment):
|
||||
"""Model for storing file attachments against a StockItem object."""
|
||||
|
@ -187,7 +187,7 @@
|
||||
<tr>
|
||||
<td><span class='fas fa-dollar-sign'></span></td>
|
||||
<td>{% trans "Purchase Price" %}</td>
|
||||
<td>{{ item.purchase_price }}</td>
|
||||
<td>{% include "price_data.html" with price=item.purchase_price %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if item.parent %}
|
||||
|
@ -15,8 +15,11 @@
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_BOM_USE_INTERNAL_PRICE" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PRICING_DECIMAL_PLACES" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PRICING_UPDATE_DAYS" icon='fa-calendar-alt' %}
|
||||
<tr><td colspan='5'></td></tr>
|
||||
{% include "InvenTree/settings/setting.html" with key="PRICING_USE_SUPPLIER_PRICING" icon='fa-check-circle' %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PRICING_PURCHASE_HISTORY_OVERRIDES_SUPPLIER" icon='fa-shopping-cart' %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PRICING_USE_STOCK_PRICING" icon='fa-boxes' %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PRICING_STOCK_ITEM_AGE_DAYS" icon='fa-calendar-alt' %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PRICING_USE_VARIANT_PRICING" icon='fa-check-circle' %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PRICING_ACTIVE_VARIANTS" %}
|
||||
|
||||
@ -57,6 +60,7 @@
|
||||
<td></td>
|
||||
<th>{% trans "Base Currency" %}</th>
|
||||
<th>{{ base_currency }}</th>
|
||||
<th colspan='2'></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
@ -70,7 +74,6 @@
|
||||
<td></td>
|
||||
<td>{{ rate.currency }}</td>
|
||||
<td>{{ rate.value }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
@ -27,14 +27,14 @@
|
||||
{% else %}
|
||||
<div id='setting-{{ setting.pk }}'>
|
||||
<span id='setting-value-{{ setting.pk }}-{{ setting.typ }}' fieldname='{{ setting.key.upper }}'>
|
||||
{% if setting.value %}
|
||||
{% if setting.value == '' %}
|
||||
<em style='color: #855;'>{% trans "No value set" %}</em>
|
||||
{% else %}
|
||||
{% if setting.is_choice %}
|
||||
<strong>{{ setting.as_choice }}</strong>
|
||||
{% else %}
|
||||
<strong>{{ setting.value }}</strong>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<em style='color: #855;'>{% trans "No value set" %}</em>
|
||||
{% endif %}
|
||||
</span>
|
||||
{{ setting.units }}
|
||||
|
Loading…
Reference in New Issue
Block a user