mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-30 04:26:44 +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:
|
if create:
|
||||||
# Attempt to create a new settings object
|
# Attempt to create a new settings object
|
||||||
|
|
||||||
|
default_value = cls.get_setting_default(key, **kwargs)
|
||||||
|
|
||||||
setting = cls(
|
setting = cls(
|
||||||
key=key,
|
key=key,
|
||||||
value=cls.get_setting_default(key, **kwargs),
|
value=default_value,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1173,6 +1176,24 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'validator': bool,
|
'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': {
|
'PRICING_USE_VARIANT_PRICING': {
|
||||||
'name': _('Use Variant Pricing'),
|
'name': _('Use Variant Pricing'),
|
||||||
'description': _('Include variant pricing in overall pricing calculations'),
|
'description': _('Include variant pricing in overall pricing calculations'),
|
||||||
|
@ -195,7 +195,7 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
{% if tp == None %}
|
{% if tp == None %}
|
||||||
<span class='badge bg-warning'>{% trans "Total cost could not be calculated" %}</span>
|
<span class='badge bg-warning'>{% trans "Total cost could not be calculated" %}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ tp }}
|
{% include "price_data.html" with price=tp %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</td>
|
</td>
|
||||||
|
@ -193,7 +193,7 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
{% if tp == None %}
|
{% if tp == None %}
|
||||||
<span class='badge bg-warning'>{% trans "Total cost could not be calculated" %}</span>
|
<span class='badge bg-warning'>{% trans "Total cost could not be calculated" %}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ tp }}
|
{% include "price_data.html" with price=tp %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</td>
|
</td>
|
||||||
|
@ -6,7 +6,7 @@ import decimal
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
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:
|
if purchase_max is None or purchase_cost > purchase_max:
|
||||||
purchase_max = purchase_cost
|
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_min = purchase_min
|
||||||
self.purchase_cost_max = purchase_max
|
self.purchase_cost_max = purchase_max
|
||||||
|
|
||||||
@ -2651,6 +2676,7 @@ class PartPricing(common.models.MetaMixin):
|
|||||||
max_costs.append(self.supplier_price_max)
|
max_costs.append(self.supplier_price_max)
|
||||||
|
|
||||||
if InvenTreeSetting.get_setting('PRICING_USE_VARIANT_PRICING', True, cache=False):
|
if InvenTreeSetting.get_setting('PRICING_USE_VARIANT_PRICING', True, cache=False):
|
||||||
|
# Include variant pricing in overall calculations
|
||||||
min_costs.append(self.variant_cost_min)
|
min_costs.append(self.variant_cost_min)
|
||||||
max_costs.append(self.variant_cost_max)
|
max_costs.append(self.variant_cost_max)
|
||||||
|
|
||||||
|
@ -45,9 +45,7 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<th>
|
<th>{% trans "Internal Pricing" %}</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_min %}</td>
|
||||||
<td>{% include "price_data.html" with price=pricing.internal_cost_max %}</td>
|
<td>{% include "price_data.html" with price=pricing.internal_cost_max %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -60,9 +58,7 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<th>
|
<th>{% trans "Purchase History" %}</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_min %}</td>
|
||||||
<td>{% include "price_data.html" with price=pricing.purchase_cost_max %}</td>
|
<td>{% include "price_data.html" with price=pricing.purchase_cost_max %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -74,9 +70,7 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<th>
|
<th>{% trans "Supplier Pricing" %}</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_min %}</td>
|
||||||
<td>{% include "price_data.html" with price=pricing.supplier_price_max %}</td>
|
<td>{% include "price_data.html" with price=pricing.supplier_price_max %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -90,9 +84,7 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<th>
|
<th>{% trans "BOM Pricing" %}</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_min %}</td>
|
||||||
<td>{% include "price_data.html" with price=pricing.bom_cost_max %}</td>
|
<td>{% include "price_data.html" with price=pricing.bom_cost_max %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -107,9 +99,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<tr>
|
<tr>
|
||||||
<td></td>
|
<td></td>
|
||||||
<th>
|
<th>{% trans "Overall Pricing" %}</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_min %}</th>
|
||||||
<th>{% include "price_data.html" with price=pricing.overall_max %}</th>
|
<th>{% include "price_data.html" with price=pricing.overall_max %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -135,15 +125,9 @@
|
|||||||
<span class='fas fa-dollar-sign'></span>
|
<span class='fas fa-dollar-sign'></span>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<th>
|
<th>{% trans "Sale Price" %}</th>
|
||||||
{% trans "Sale Price" %}
|
<td>{% include "price_data.html" with price=pricing.sale_price_min %}</td>
|
||||||
</th>
|
<td>{% include "price_data.html" with price=pricing.sale_price_max %}</td>
|
||||||
<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>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
@ -151,15 +135,9 @@
|
|||||||
<span class='fas fa-chart-line'></span>
|
<span class='fas fa-chart-line'></span>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<th>
|
<th>{% trans "Sale History" %}</th>
|
||||||
{% trans "Sale History" %}
|
<td>{% include "price_data.html" with price=pricing.sale_history_min %}</td>
|
||||||
</th>
|
<td>{% include "price_data.html" with price=pricing.sale_history_max %}</td>
|
||||||
<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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
@ -10,6 +10,7 @@ import common.settings
|
|||||||
import company.models
|
import company.models
|
||||||
import order.models
|
import order.models
|
||||||
import part.models
|
import part.models
|
||||||
|
import stock.models
|
||||||
from InvenTree.helpers import InvenTreeTestCase
|
from InvenTree.helpers import InvenTreeTestCase
|
||||||
from InvenTree.status_codes import PurchaseOrderStatus
|
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.internal_cost_max, Money(10, currency))
|
||||||
self.assertEqual(pricing.overall_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):
|
def test_bom_pricing(self):
|
||||||
"""Unit test for BOM pricing calculations"""
|
"""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
|
# Run this check in the background
|
||||||
InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance.part)
|
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')
|
@receiver(post_save, sender=StockItem, dispatch_uid='stock_item_post_save_log')
|
||||||
def after_save_stock_item(sender, instance: StockItem, created, **kwargs):
|
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
|
# Run this check in the background
|
||||||
InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance.part)
|
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):
|
class StockItemAttachment(InvenTreeAttachment):
|
||||||
"""Model for storing file attachments against a StockItem object."""
|
"""Model for storing file attachments against a StockItem object."""
|
||||||
|
@ -187,7 +187,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-dollar-sign'></span></td>
|
<td><span class='fas fa-dollar-sign'></span></td>
|
||||||
<td>{% trans "Purchase Price" %}</td>
|
<td>{% trans "Purchase Price" %}</td>
|
||||||
<td>{{ item.purchase_price }}</td>
|
<td>{% include "price_data.html" with price=item.purchase_price %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if item.parent %}
|
{% 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="PART_BOM_USE_INTERNAL_PRICE" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PRICING_DECIMAL_PLACES" %}
|
{% include "InvenTree/settings/setting.html" with key="PRICING_DECIMAL_PLACES" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PRICING_UPDATE_DAYS" icon='fa-calendar-alt' %}
|
{% 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_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_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_USE_VARIANT_PRICING" icon='fa-check-circle' %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="PRICING_ACTIVE_VARIANTS" %}
|
{% include "InvenTree/settings/setting.html" with key="PRICING_ACTIVE_VARIANTS" %}
|
||||||
|
|
||||||
@ -57,6 +60,7 @@
|
|||||||
<td></td>
|
<td></td>
|
||||||
<th>{% trans "Base Currency" %}</th>
|
<th>{% trans "Base Currency" %}</th>
|
||||||
<th>{{ base_currency }}</th>
|
<th>{{ base_currency }}</th>
|
||||||
|
<th colspan='2'></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td></td>
|
<td></td>
|
||||||
@ -70,7 +74,6 @@
|
|||||||
<td></td>
|
<td></td>
|
||||||
<td>{{ rate.currency }}</td>
|
<td>{{ rate.currency }}</td>
|
||||||
<td>{{ rate.value }}</td>
|
<td>{{ rate.value }}</td>
|
||||||
<td></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -27,14 +27,14 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div id='setting-{{ setting.pk }}'>
|
<div id='setting-{{ setting.pk }}'>
|
||||||
<span id='setting-value-{{ setting.pk }}-{{ setting.typ }}' fieldname='{{ setting.key.upper }}'>
|
<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 %}
|
{% if setting.is_choice %}
|
||||||
<strong>{{ setting.as_choice }}</strong>
|
<strong>{{ setting.as_choice }}</strong>
|
||||||
{% else %}
|
{% else %}
|
||||||
<strong>{{ setting.value }}</strong>
|
<strong>{{ setting.value }}</strong>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
|
||||||
<em style='color: #855;'>{% trans "No value set" %}</em>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
{{ setting.units }}
|
{{ setting.units }}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user