mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +00:00
Calculate weighted average price when merging stock items (#7534)
* Calculate weighted average price when merging stock items * refactor currency averaging - Only add samples which have an associated value * Revert to using two loops * Check for div-by-zero * Add unit testing for purchase price averaging
This commit is contained in:
parent
3b3352119f
commit
fd91085363
@ -20,6 +20,7 @@ from django.dispatch import receiver
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from djmoney.contrib.exchange.models import convert_money
|
||||||
from mptt.managers import TreeManager
|
from mptt.managers import TreeManager
|
||||||
from mptt.models import MPTTModel, TreeForeignKey
|
from mptt.models import MPTTModel, TreeForeignKey
|
||||||
from taggit.managers import TaggableManager
|
from taggit.managers import TaggableManager
|
||||||
@ -1706,6 +1707,12 @@ class StockItem(
|
|||||||
|
|
||||||
parent_id = self.parent.pk if self.parent else None
|
parent_id = self.parent.pk if self.parent else None
|
||||||
|
|
||||||
|
# Keep track of pricing data for the merged data
|
||||||
|
pricing_data = []
|
||||||
|
|
||||||
|
if self.purchase_price:
|
||||||
|
pricing_data.append([self.purchase_price, self.quantity])
|
||||||
|
|
||||||
for other in other_items:
|
for other in other_items:
|
||||||
# If the stock item cannot be merged, return
|
# If the stock item cannot be merged, return
|
||||||
if not self.can_merge(other, raise_error=raise_error, **kwargs):
|
if not self.can_merge(other, raise_error=raise_error, **kwargs):
|
||||||
@ -1714,11 +1721,15 @@ class StockItem(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
for other in other_items:
|
||||||
tree_ids.add(other.tree_id)
|
tree_ids.add(other.tree_id)
|
||||||
|
|
||||||
for other in other_items:
|
|
||||||
self.quantity += other.quantity
|
self.quantity += other.quantity
|
||||||
|
|
||||||
|
if other.purchase_price:
|
||||||
|
# Only add pricing data if it is available
|
||||||
|
pricing_data.append([other.purchase_price, other.quantity])
|
||||||
|
|
||||||
# Any "build order allocations" for the other item must be assigned to this one
|
# Any "build order allocations" for the other item must be assigned to this one
|
||||||
for allocation in other.allocations.all():
|
for allocation in other.allocations.all():
|
||||||
allocation.stock_item = self
|
allocation.stock_item = self
|
||||||
@ -1744,7 +1755,31 @@ class StockItem(
|
|||||||
deltas={'location': location.pk if location else None},
|
deltas={'location': location.pk if location else None},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Update the location of the item
|
||||||
self.location = location
|
self.location = location
|
||||||
|
|
||||||
|
# Update the unit price - calculate weighted average of available pricing data
|
||||||
|
if len(pricing_data) > 0:
|
||||||
|
unit_price, quantity = pricing_data[0]
|
||||||
|
|
||||||
|
# Use the first currency as the base currency
|
||||||
|
base_currency = unit_price.currency
|
||||||
|
|
||||||
|
total_price = unit_price * quantity
|
||||||
|
|
||||||
|
for price, qty in pricing_data[1:]:
|
||||||
|
# Attempt to convert the price to the base currency
|
||||||
|
try:
|
||||||
|
price = convert_money(price, base_currency)
|
||||||
|
total_price += price * qty
|
||||||
|
quantity += qty
|
||||||
|
except:
|
||||||
|
# Skip this entry, cannot convert to base currency
|
||||||
|
continue
|
||||||
|
|
||||||
|
if quantity > 0:
|
||||||
|
self.purchase_price = total_price / quantity
|
||||||
|
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
# Rebuild stock trees as required
|
# Rebuild stock trees as required
|
||||||
|
@ -755,23 +755,14 @@ class StockTest(StockTestBase):
|
|||||||
# First, we will create a stock location structure
|
# First, we will create a stock location structure
|
||||||
|
|
||||||
A = StockLocation.objects.create(name='A', description='Top level location')
|
A = StockLocation.objects.create(name='A', description='Top level location')
|
||||||
|
|
||||||
B1 = StockLocation.objects.create(name='B1', parent=A)
|
B1 = StockLocation.objects.create(name='B1', parent=A)
|
||||||
|
|
||||||
B2 = StockLocation.objects.create(name='B2', parent=A)
|
B2 = StockLocation.objects.create(name='B2', parent=A)
|
||||||
|
|
||||||
B3 = StockLocation.objects.create(name='B3', parent=A)
|
B3 = StockLocation.objects.create(name='B3', parent=A)
|
||||||
|
|
||||||
C11 = StockLocation.objects.create(name='C11', parent=B1)
|
C11 = StockLocation.objects.create(name='C11', parent=B1)
|
||||||
|
|
||||||
C12 = StockLocation.objects.create(name='C12', parent=B1)
|
C12 = StockLocation.objects.create(name='C12', parent=B1)
|
||||||
|
|
||||||
C21 = StockLocation.objects.create(name='C21', parent=B2)
|
C21 = StockLocation.objects.create(name='C21', parent=B2)
|
||||||
|
|
||||||
C22 = StockLocation.objects.create(name='C22', parent=B2)
|
C22 = StockLocation.objects.create(name='C22', parent=B2)
|
||||||
|
|
||||||
C31 = StockLocation.objects.create(name='C31', parent=B3)
|
C31 = StockLocation.objects.create(name='C31', parent=B3)
|
||||||
|
|
||||||
C32 = StockLocation.objects.create(name='C32', parent=B3)
|
C32 = StockLocation.objects.create(name='C32', parent=B3)
|
||||||
|
|
||||||
# Check that the tree_id is correct for each sublocation
|
# Check that the tree_id is correct for each sublocation
|
||||||
@ -895,6 +886,62 @@ class StockTest(StockTestBase):
|
|||||||
|
|
||||||
self.assertEqual(len(p.metadata.keys()), 4)
|
self.assertEqual(len(p.metadata.keys()), 4)
|
||||||
|
|
||||||
|
def test_merge(self):
|
||||||
|
"""Test merging of multiple stock items."""
|
||||||
|
from djmoney.money import Money
|
||||||
|
|
||||||
|
part = Part.objects.first()
|
||||||
|
part.stock_items.all().delete()
|
||||||
|
|
||||||
|
# Test simple merge without any pricing information
|
||||||
|
s1 = StockItem.objects.create(part=part, quantity=10)
|
||||||
|
s2 = StockItem.objects.create(part=part, quantity=20)
|
||||||
|
s3 = StockItem.objects.create(part=part, quantity=30)
|
||||||
|
|
||||||
|
self.assertEqual(part.stock_items.count(), 3)
|
||||||
|
s1.merge_stock_items([s2, s3])
|
||||||
|
self.assertEqual(part.stock_items.count(), 1)
|
||||||
|
s1.refresh_from_db()
|
||||||
|
self.assertEqual(s1.quantity, 60)
|
||||||
|
self.assertIsNone(s1.purchase_price)
|
||||||
|
|
||||||
|
part.stock_items.all().delete()
|
||||||
|
|
||||||
|
# Create some stock items with pricing information
|
||||||
|
s1 = StockItem.objects.create(part=part, quantity=10, purchase_price=None)
|
||||||
|
s2 = StockItem.objects.create(
|
||||||
|
part=part, quantity=15, purchase_price=Money(10, 'USD')
|
||||||
|
)
|
||||||
|
s3 = StockItem.objects.create(part=part, quantity=30)
|
||||||
|
|
||||||
|
self.assertEqual(part.stock_items.count(), 3)
|
||||||
|
s1.merge_stock_items([s2, s3])
|
||||||
|
self.assertEqual(part.stock_items.count(), 1)
|
||||||
|
s1.refresh_from_db()
|
||||||
|
self.assertEqual(s1.quantity, 55)
|
||||||
|
self.assertEqual(s1.purchase_price, Money(10, 'USD'))
|
||||||
|
|
||||||
|
part.stock_items.all().delete()
|
||||||
|
|
||||||
|
s1 = StockItem.objects.create(
|
||||||
|
part=part, quantity=10, purchase_price=Money(5, 'USD')
|
||||||
|
)
|
||||||
|
s2 = StockItem.objects.create(
|
||||||
|
part=part, quantity=25, purchase_price=Money(10, 'USD')
|
||||||
|
)
|
||||||
|
s3 = StockItem.objects.create(
|
||||||
|
part=part, quantity=5, purchase_price=Money(75, 'USD')
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(part.stock_items.count(), 3)
|
||||||
|
s1.merge_stock_items([s2, s3])
|
||||||
|
self.assertEqual(part.stock_items.count(), 1)
|
||||||
|
s1.refresh_from_db()
|
||||||
|
self.assertEqual(s1.quantity, 40)
|
||||||
|
|
||||||
|
# Final purchase price should be the weighted average
|
||||||
|
self.assertAlmostEqual(s1.purchase_price.amount, 16.875, places=3)
|
||||||
|
|
||||||
|
|
||||||
class StockBarcodeTest(StockTestBase):
|
class StockBarcodeTest(StockTestBase):
|
||||||
"""Run barcode tests for the stock app."""
|
"""Run barcode tests for the stock app."""
|
||||||
|
Loading…
x
Reference in New Issue
Block a user