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:
Oliver 2023-02-02 21:23:16 +11:00 committed by GitHub
parent df4209801a
commit 9a289948e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 126 additions and 42 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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