Skip to content

Commit

Permalink
Calculate weighted average price when merging stock items (#7534)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
SchrodingersGat authored Jun 29, 2024
1 parent 3b33521 commit fd91085
Show file tree
Hide file tree
Showing 2 changed files with 92 additions and 10 deletions.
37 changes: 36 additions & 1 deletion src/backend/InvenTree/stock/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from django.urls import reverse
from django.utils.translation import gettext_lazy as _

from djmoney.contrib.exchange.models import convert_money
from mptt.managers import TreeManager
from mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager
Expand Down Expand Up @@ -1706,6 +1707,12 @@ def merge_stock_items(self, other_items, raise_error=False, **kwargs):

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:
# If the stock item cannot be merged, return
if not self.can_merge(other, raise_error=raise_error, **kwargs):
Expand All @@ -1714,11 +1721,15 @@ def merge_stock_items(self, other_items, raise_error=False, **kwargs):
)
return

for other in other_items:
tree_ids.add(other.tree_id)

for other in other_items:
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
for allocation in other.allocations.all():
allocation.stock_item = self
Expand All @@ -1744,7 +1755,31 @@ def merge_stock_items(self, other_items, raise_error=False, **kwargs):
deltas={'location': location.pk if location else None},
)

# Update the location of the item
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()

# Rebuild stock trees as required
Expand Down
65 changes: 56 additions & 9 deletions src/backend/InvenTree/stock/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -755,23 +755,14 @@ def test_location_tree(self):
# First, we will create a stock location structure

A = StockLocation.objects.create(name='A', description='Top level location')

B1 = StockLocation.objects.create(name='B1', parent=A)

B2 = StockLocation.objects.create(name='B2', parent=A)

B3 = StockLocation.objects.create(name='B3', parent=A)

C11 = StockLocation.objects.create(name='C11', parent=B1)

C12 = StockLocation.objects.create(name='C12', parent=B1)

C21 = StockLocation.objects.create(name='C21', parent=B2)

C22 = StockLocation.objects.create(name='C22', parent=B2)

C31 = StockLocation.objects.create(name='C31', parent=B3)

C32 = StockLocation.objects.create(name='C32', parent=B3)

# Check that the tree_id is correct for each sublocation
Expand Down Expand Up @@ -895,6 +886,62 @@ def test_metadata(self):

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):
"""Run barcode tests for the stock app."""
Expand Down

0 comments on commit fd91085

Please sign in to comment.