From 9a289948e55defad2891a6bfd57692284ab87449 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 2 Feb 2023 21:23:16 +1100 Subject: [PATCH] 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 --- InvenTree/common/models.py | 23 ++++++++- .../order/templates/order/order_base.html | 2 +- .../templates/order/sales_order_base.html | 2 +- InvenTree/part/models.py | 28 ++++++++++- InvenTree/part/templates/part/prices.html | 44 +++++------------ InvenTree/part/test_pricing.py | 49 +++++++++++++++++++ InvenTree/stock/models.py | 7 +++ .../stock/templates/stock/item_base.html | 2 +- .../templates/InvenTree/settings/pricing.html | 5 +- .../templates/InvenTree/settings/setting.html | 6 +-- 10 files changed, 126 insertions(+), 42 deletions(-) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 283a386c59..900ab57669 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -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'), diff --git a/InvenTree/order/templates/order/order_base.html b/InvenTree/order/templates/order/order_base.html index 057d53aad1..ff0a13f0d7 100644 --- a/InvenTree/order/templates/order/order_base.html +++ b/InvenTree/order/templates/order/order_base.html @@ -195,7 +195,7 @@ src="{% static 'img/blank_image.png' %}" {% if tp == None %} {% trans "Total cost could not be calculated" %} {% else %} - {{ tp }} + {% include "price_data.html" with price=tp %} {% endif %} {% endwith %} diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html index cb7f998578..b0ab15be67 100644 --- a/InvenTree/order/templates/order/sales_order_base.html +++ b/InvenTree/order/templates/order/sales_order_base.html @@ -193,7 +193,7 @@ src="{% static 'img/blank_image.png' %}" {% if tp == None %} {% trans "Total cost could not be calculated" %} {% else %} - {{ tp }} + {% include "price_data.html" with price=tp %} {% endif %} {% endwith %} diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 18e23b1ec6..96ffca62a4 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -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) diff --git a/InvenTree/part/templates/part/prices.html b/InvenTree/part/templates/part/prices.html index e34b2d3f09..380720408b 100644 --- a/InvenTree/part/templates/part/prices.html +++ b/InvenTree/part/templates/part/prices.html @@ -45,9 +45,7 @@ {% endif %} - - {% trans "Internal Pricing" %} - + {% trans "Internal Pricing" %} {% include "price_data.html" with price=pricing.internal_cost_min %} {% include "price_data.html" with price=pricing.internal_cost_max %} @@ -60,9 +58,7 @@ {% endif %} - - {% trans "Purchase History" %} - + {% trans "Purchase History" %} {% include "price_data.html" with price=pricing.purchase_cost_min %} {% include "price_data.html" with price=pricing.purchase_cost_max %} @@ -74,9 +70,7 @@ {% endif %} - - {% trans "Supplier Pricing" %} - + {% trans "Supplier Pricing" %} {% include "price_data.html" with price=pricing.supplier_price_min %} {% include "price_data.html" with price=pricing.supplier_price_max %} @@ -90,9 +84,7 @@ {% endif %} - - {% trans "BOM Pricing" %} - + {% trans "BOM Pricing" %} {% include "price_data.html" with price=pricing.bom_cost_min %} {% include "price_data.html" with price=pricing.bom_cost_max %} @@ -107,9 +99,7 @@ {% endif %} - - {% trans "Overall Pricing" %} - + {% trans "Overall Pricing" %} {% include "price_data.html" with price=pricing.overall_min %} {% include "price_data.html" with price=pricing.overall_max %} @@ -135,15 +125,9 @@ - - {% trans "Sale Price" %} - - - {% include "price_data.html" with price=pricing.sale_price_min %} - - - {% include "price_data.html" with price=pricing.sale_price_max %} - + {% trans "Sale Price" %} + {% include "price_data.html" with price=pricing.sale_price_min %} + {% include "price_data.html" with price=pricing.sale_price_max %} @@ -151,15 +135,9 @@ - - {% trans "Sale History" %} - - - {% include "price_data.html" with price=pricing.sale_history_min %} - - - {% include "price_data.html" with price=pricing.sale_history_max %} - + {% trans "Sale History" %} + {% include "price_data.html" with price=pricing.sale_history_min %} + {% include "price_data.html" with price=pricing.sale_history_max %} diff --git a/InvenTree/part/test_pricing.py b/InvenTree/part/test_pricing.py index b938e73073..8e715e4d7c 100644 --- a/InvenTree/part/test_pricing.py +++ b/InvenTree/part/test_pricing.py @@ -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""" diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 58c9065e49..2d0cfdb86d 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -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.""" diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index c46f39ed39..16e023fac9 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -187,7 +187,7 @@ {% trans "Purchase Price" %} - {{ item.purchase_price }} + {% include "price_data.html" with price=item.purchase_price %} {% endif %} {% if item.parent %} diff --git a/InvenTree/templates/InvenTree/settings/pricing.html b/InvenTree/templates/InvenTree/settings/pricing.html index f05103e95e..fc9bb88f77 100644 --- a/InvenTree/templates/InvenTree/settings/pricing.html +++ b/InvenTree/templates/InvenTree/settings/pricing.html @@ -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' %} + {% 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 @@ {% trans "Base Currency" %} {{ base_currency }} + @@ -70,7 +74,6 @@ {{ rate.currency }} {{ rate.value }} - {% endfor %} diff --git a/InvenTree/templates/InvenTree/settings/setting.html b/InvenTree/templates/InvenTree/settings/setting.html index 4a9192924a..1b9a020658 100644 --- a/InvenTree/templates/InvenTree/settings/setting.html +++ b/InvenTree/templates/InvenTree/settings/setting.html @@ -27,14 +27,14 @@ {% else %}
- {% if setting.value %} + {% if setting.value == '' %} + {% trans "No value set" %} + {% else %} {% if setting.is_choice %} {{ setting.as_choice }} {% else %} {{ setting.value }} {% endif %} - {% else %} - {% trans "No value set" %} {% endif %} {{ setting.units }}