2
0
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:
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: 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'),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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