mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-16 12:05:53 +00:00
* Add new BuildLine model - Represents an instance of a BOM item against a BuildOrder * Create BuildLine instances automatically When a new Build is created, automatically generate new BuildLine items * Improve logic for handling exchange rate backends * logic fixes * Adds API endpoints Add list and detail API endpoints for new BuildLine model * update users/models.py - Add new model to roles definition * bulk-create on auto_allocate Save database hits by performing a bulk-create * Add skeleton data migration * Create BuildLines for existing orders * Working on building out BuildLine table * Adds link for "BuildLine" to "BuildItem" - A "BuildItem" will now be tracked against a BuildLine - Not tracked directly against a build - Not tracked directly against a BomItem - Add schema migration - Add data migration to update links * Adjust migration 0045 - bom_item and build fields are about to be removed - Set them to "nullable" so the data doesn't get removed * Remove old fields from BuildItem model - build fk - bom_item fk - A lot of other required changes too * Update BuildLine.bom_item field - Delete the BuildLine if the BomItem is removed - This is closer to current behaviour * Cleanup for Build model - tracked_bom_items -> tracked_line_items - untracked_bom_items -> tracked_bom_items - remove build.can_complete - move bom_item specific methods to the BuildLine model - Cleanup / consolidation * front-end work - Update javascript - Cleanup HTML templates * Add serializer annotation and filtering - Annotate 'allocated' quantity - Filter by allocated / trackable / optional / consumable * Make table sortable * Add buttons * Add callback for building new stock * Fix Part annotation * Adds callback to order parts * Allocation works again * template cleanup * Fix allocate / unallocate actions - Also turns out "unallocate" is not a word.. * auto-allocate works again * Fix call to build.is_over_allocated * Refactoring updates * Bump API version * Cleaner implementation of allocation sub-table * Fix rendering in build output table * Improvements to StockItem list API - Refactor very old code - Add option to include test results to queryset * Add TODO for later me * Fix for serializers.py * Working on cleaner implementation of build output table * Add function to determine if a single output is fully allocated * Updates to build.js - Button callbacks - Table rendering * Revert previous changes to build.serializers.py * Fix for forms.js * Rearrange code in build.js * Rebuild "allocated lines" for output table * Fix allocation calculation * Show or hide column for tracked parts * Improve debug messages * Refactor "loadBuildLineTable" - Allow it to also be used as output sub-table * Refactor "completed tests" column * Remove old javascript - Cleans up a *lot* of crusty old code * Annotate the available stock quantity to BuildLine serializer - Similar pattern to BomItem serializer - Needs refactoring in the future * Update available column * Fix build allocation table - Bug fix - Make pretty * linting fixes * Allow sorting by available stock * Tweak for "required tests" column * Bug fix for completing a build output * Fix for consumable stock * Fix for trim_allocated_stock * Fix for creating new build * Migration fix - Ensure initial django_q migrations are applied - Why on earth is this failing now? * Catch exception * Update for exception handling * Update migrations - Ensure inventreesetting is added * Catch all exceptions when getting default currency code * Bug fix for currency exchange rates update * Working on unit tests * Unit test fixes * More work on unit tests * Use bulk_create in unit test * Update required quantity when a BuildOrder is saved * Tweak overage display in BOM table * Fix icon in BOM table * Fix spelling error * More unit test fixes * Build reports - Add line_items - Update docs - Cleanup * Reimplement is_partially_allocated method * Update docs about overage * Unit testing for data migration * Add "required_for_build_orders" annotation - Makes API query *much* faster now - remove old "required_parts_to_complete_build" method - Cleanup part API filter code * Adjust order of fixture loading * Fix unit test * Prevent "schedule_pricing_update" in unit tests - Should cut down on DB hits significantly * Unit test updates * Improvements for unit test - Don't hard-code pk values - postgresql no likey * Better unit test
486 lines
16 KiB
Python
486 lines
16 KiB
Python
"""Unit tests for Part pricing calculations"""
|
|
|
|
from django.core.exceptions import ObjectDoesNotExist
|
|
|
|
from djmoney.contrib.exchange.models import convert_money
|
|
from djmoney.money import Money
|
|
|
|
import common.models
|
|
import common.settings
|
|
import company.models
|
|
import order.models
|
|
import part.models
|
|
import stock.models
|
|
from InvenTree.status_codes import PurchaseOrderStatus
|
|
from InvenTree.unit_test import InvenTreeTestCase
|
|
|
|
|
|
class PartPricingTests(InvenTreeTestCase):
|
|
"""Unit tests for part pricing calculations"""
|
|
|
|
def setUp(self):
|
|
"""Setup routines"""
|
|
|
|
super().setUp()
|
|
|
|
self.generate_exchange_rates()
|
|
|
|
# Create a new part for performing pricing calculations
|
|
# We will use 'metres' for the UOM here
|
|
# Some SupplierPart instances will have different units!
|
|
self.part = part.models.Part.objects.create(
|
|
name='PP',
|
|
description='A part with pricing, measured in metres',
|
|
assembly=True,
|
|
units='m'
|
|
)
|
|
|
|
def create_price_breaks(self):
|
|
"""Create some price breaks for the part, in various currencies"""
|
|
|
|
# First supplier part (CAD)
|
|
self.supplier_1 = company.models.Company.objects.create(
|
|
name='Supplier 1',
|
|
is_supplier=True
|
|
)
|
|
|
|
self.sp_1 = company.models.SupplierPart.objects.create(
|
|
supplier=self.supplier_1,
|
|
part=self.part,
|
|
SKU='SUP_1',
|
|
pack_quantity='200 cm',
|
|
)
|
|
|
|
# Native pack quantity should be 2m
|
|
self.assertEqual(self.sp_1.pack_quantity_native, 2)
|
|
|
|
company.models.SupplierPriceBreak.objects.create(
|
|
part=self.sp_1,
|
|
quantity=1,
|
|
price=10.4,
|
|
price_currency='CAD',
|
|
)
|
|
|
|
# Second supplier part (AUD)
|
|
self.supplier_2 = company.models.Company.objects.create(
|
|
name='Supplier 2',
|
|
is_supplier=True
|
|
)
|
|
|
|
self.sp_2 = company.models.SupplierPart.objects.create(
|
|
supplier=self.supplier_2,
|
|
part=self.part,
|
|
SKU='SUP_2',
|
|
pack_quantity='2.5',
|
|
)
|
|
|
|
# Native pack quantity should be 2.5m
|
|
self.assertEqual(self.sp_2.pack_quantity_native, 2.5)
|
|
|
|
self.sp_3 = company.models.SupplierPart.objects.create(
|
|
supplier=self.supplier_2,
|
|
part=self.part,
|
|
SKU='SUP_3',
|
|
pack_quantity='10 inches',
|
|
)
|
|
|
|
# Native pack quantity should be 0.254m
|
|
self.assertEqual(self.sp_3.pack_quantity_native, 0.254)
|
|
|
|
company.models.SupplierPriceBreak.objects.create(
|
|
part=self.sp_2,
|
|
quantity=5,
|
|
price=7.555,
|
|
price_currency='AUD',
|
|
)
|
|
|
|
# Third supplier part (GBP)
|
|
company.models.SupplierPriceBreak.objects.create(
|
|
part=self.sp_2,
|
|
quantity=10,
|
|
price=4.55,
|
|
price_currency='GBP',
|
|
)
|
|
|
|
def test_pricing_data(self):
|
|
"""Test link between Part and PartPricing model"""
|
|
|
|
# Initially there is no associated Pricing data
|
|
with self.assertRaises(ObjectDoesNotExist):
|
|
pricing = self.part.pricing_data
|
|
|
|
# Accessing in this manner should create the associated PartPricing instance
|
|
pricing = self.part.pricing
|
|
|
|
self.assertEqual(pricing.part, self.part)
|
|
|
|
# Default values should be null
|
|
self.assertIsNone(pricing.bom_cost_min)
|
|
self.assertIsNone(pricing.bom_cost_max)
|
|
|
|
self.assertIsNone(pricing.internal_cost_min)
|
|
self.assertIsNone(pricing.internal_cost_max)
|
|
|
|
self.assertIsNone(pricing.overall_min)
|
|
self.assertIsNone(pricing.overall_max)
|
|
|
|
def test_invalid_rate(self):
|
|
"""Ensure that conversion behaves properly with missing rates"""
|
|
...
|
|
|
|
def test_simple(self):
|
|
"""Tests for hard-coded values"""
|
|
|
|
pricing = self.part.pricing
|
|
|
|
# Add internal pricing
|
|
pricing.internal_cost_min = Money(1, 'USD')
|
|
pricing.internal_cost_max = Money(4, 'USD')
|
|
pricing.save()
|
|
|
|
self.assertEqual(pricing.overall_min, Money('1', 'USD'))
|
|
self.assertEqual(pricing.overall_max, Money('4', 'USD'))
|
|
|
|
# Add supplier pricing
|
|
pricing.supplier_price_min = Money(10, 'AUD')
|
|
pricing.supplier_price_max = Money(15, 'CAD')
|
|
pricing.save()
|
|
|
|
# Minimum pricing should not have changed
|
|
self.assertEqual(pricing.overall_min, Money('1', 'USD'))
|
|
|
|
# Maximum price has changed, and was specified in a different currency
|
|
self.assertEqual(pricing.overall_max, Money('8.823529', 'USD'))
|
|
|
|
# Add BOM cost
|
|
pricing.bom_cost_min = Money(0.1, 'GBP')
|
|
pricing.bom_cost_max = Money(25, 'USD')
|
|
pricing.save()
|
|
|
|
self.assertEqual(pricing.overall_min, Money('0.111111', 'USD'))
|
|
self.assertEqual(pricing.overall_max, Money('25', 'USD'))
|
|
|
|
def test_supplier_part_pricing(self):
|
|
"""Test for supplier part pricing"""
|
|
|
|
pricing = self.part.pricing
|
|
|
|
# Initially, no information (not yet calculated)
|
|
self.assertIsNone(pricing.supplier_price_min)
|
|
self.assertIsNone(pricing.supplier_price_max)
|
|
self.assertIsNone(pricing.overall_min)
|
|
self.assertIsNone(pricing.overall_max)
|
|
|
|
# Creating price breaks will cause the pricing to be updated
|
|
self.create_price_breaks()
|
|
|
|
pricing.update_pricing()
|
|
|
|
self.assertAlmostEqual(float(pricing.overall_min.amount), 2.015, places=2)
|
|
self.assertAlmostEqual(float(pricing.overall_max.amount), 3.06, places=2)
|
|
|
|
# Delete all supplier parts and re-calculate
|
|
self.part.supplier_parts.all().delete()
|
|
pricing.update_pricing()
|
|
pricing.refresh_from_db()
|
|
|
|
self.assertIsNone(pricing.supplier_price_min)
|
|
self.assertIsNone(pricing.supplier_price_max)
|
|
|
|
def test_internal_pricing(self):
|
|
"""Tests for internal price breaks"""
|
|
|
|
# Ensure internal pricing is enabled
|
|
common.models.InvenTreeSetting.set_setting('PART_INTERNAL_PRICE', True, None)
|
|
|
|
pricing = self.part.pricing
|
|
|
|
# Initially, no internal price breaks
|
|
self.assertIsNone(pricing.internal_cost_min)
|
|
self.assertIsNone(pricing.internal_cost_max)
|
|
|
|
currency = common.settings.currency_code_default()
|
|
|
|
for ii in range(5):
|
|
# Let's add some internal price breaks
|
|
part.models.PartInternalPriceBreak.objects.create(
|
|
part=self.part,
|
|
quantity=ii + 1,
|
|
price=10 - ii,
|
|
price_currency=currency
|
|
)
|
|
|
|
pricing.update_internal_cost()
|
|
|
|
# Expected money value
|
|
m_expected = Money(10 - ii, currency)
|
|
|
|
# Minimum cost should keep decreasing as we add more items
|
|
self.assertEqual(pricing.internal_cost_min, m_expected)
|
|
self.assertEqual(pricing.overall_min, m_expected)
|
|
|
|
# Maximum cost should stay the same
|
|
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, this is a part description',
|
|
)
|
|
|
|
# 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"""
|
|
|
|
pricing = self.part.pricing
|
|
|
|
self.assertIsNone(pricing.bom_cost_min)
|
|
self.assertIsNone(pricing.bom_cost_max)
|
|
|
|
currency = 'AUD'
|
|
|
|
for ii in range(10):
|
|
# Create a new part for the BOM
|
|
sub_part = part.models.Part.objects.create(
|
|
name=f"Sub Part {ii}",
|
|
description="A sub part for use in a BOM",
|
|
component=True,
|
|
assembly=False,
|
|
)
|
|
|
|
# Create some overall pricing
|
|
sub_part_pricing = sub_part.pricing
|
|
|
|
# Manually override internal price
|
|
sub_part_pricing.internal_cost_min = Money(2 * (ii + 1), currency)
|
|
sub_part_pricing.internal_cost_max = Money(3 * (ii + 1), currency)
|
|
sub_part_pricing.save()
|
|
|
|
part.models.BomItem.objects.create(
|
|
part=self.part,
|
|
sub_part=sub_part,
|
|
quantity=5,
|
|
)
|
|
|
|
pricing.update_bom_cost()
|
|
|
|
# Check that the values have been updated correctly
|
|
self.assertEqual(pricing.currency, 'USD')
|
|
|
|
# Final overall pricing checks
|
|
self.assertEqual(pricing.overall_min, Money('366.666665', 'USD'))
|
|
self.assertEqual(pricing.overall_max, Money('550', 'USD'))
|
|
|
|
def test_purchase_pricing(self):
|
|
"""Unit tests for historical purchase pricing"""
|
|
|
|
self.create_price_breaks()
|
|
|
|
pricing = self.part.pricing
|
|
|
|
# Pre-calculation, pricing should be null
|
|
|
|
self.assertIsNone(pricing.purchase_cost_min)
|
|
self.assertIsNone(pricing.purchase_cost_max)
|
|
|
|
# Generate some purchase orders
|
|
po = order.models.PurchaseOrder.objects.create(
|
|
supplier=self.supplier_2,
|
|
reference='PO-009',
|
|
)
|
|
|
|
# Add some line items to the order
|
|
|
|
# $5 AUD each @ 2.5m per unit = $2 AUD per metre
|
|
line_1 = po.add_line_item(self.sp_2, quantity=10, purchase_price=Money(5, 'AUD'))
|
|
|
|
# $3 CAD each @ 10 inches per unit = $0.3 CAD per inch = $11.81 CAD per metre
|
|
line_2 = po.add_line_item(self.sp_3, quantity=5, purchase_price=Money(3, 'CAD'))
|
|
|
|
pricing.update_purchase_cost()
|
|
|
|
# Cost is still null, as the order is not complete
|
|
self.assertIsNone(pricing.purchase_cost_min)
|
|
self.assertIsNone(pricing.purchase_cost_max)
|
|
|
|
po.status = PurchaseOrderStatus.COMPLETE.value
|
|
po.save()
|
|
|
|
pricing.update_purchase_cost()
|
|
|
|
# Cost is still null, as the lines have not been received
|
|
self.assertIsNone(pricing.purchase_cost_min)
|
|
self.assertIsNone(pricing.purchase_cost_max)
|
|
|
|
# Mark items as received
|
|
line_1.received = 4
|
|
line_1.save()
|
|
|
|
line_2.received = 5
|
|
line_2.save()
|
|
|
|
pricing.update_purchase_cost()
|
|
|
|
min_cost_aud = convert_money(pricing.purchase_cost_min, 'AUD')
|
|
max_cost_cad = convert_money(pricing.purchase_cost_max, 'CAD')
|
|
|
|
# Min cost in AUD = $2 AUD per metre
|
|
self.assertAlmostEqual(float(min_cost_aud.amount), 2, places=2)
|
|
|
|
# Min cost in USD
|
|
self.assertAlmostEqual(float(pricing.purchase_cost_min.amount), 1.3333, places=2)
|
|
|
|
# Max cost in CAD = $11.81 CAD per metre
|
|
self.assertAlmostEqual(float(max_cost_cad.amount), 11.81, places=2)
|
|
|
|
# Max cost in USD
|
|
self.assertAlmostEqual(float(pricing.purchase_cost_max.amount), 6.95, places=2)
|
|
|
|
def test_delete_with_pricing(self):
|
|
"""Test for deleting a part which has pricing information"""
|
|
|
|
# Create some pricing data
|
|
self.create_price_breaks()
|
|
|
|
# Check that pricing does exist
|
|
pricing = self.part.pricing
|
|
|
|
pricing.update_pricing()
|
|
pricing.save()
|
|
|
|
self.assertIsNotNone(pricing.overall_min)
|
|
self.assertIsNotNone(pricing.overall_max)
|
|
|
|
self.part.active = False
|
|
self.part.save()
|
|
|
|
# Remove the part from the database
|
|
self.part.delete()
|
|
|
|
# Check that the pricing was removed also
|
|
with self.assertRaises(part.models.PartPricing.DoesNotExist):
|
|
pricing.refresh_from_db()
|
|
|
|
def test_delete_without_pricing(self):
|
|
"""Test that we can delete a part which does not have pricing information"""
|
|
|
|
pricing = self.part.pricing
|
|
|
|
self.assertIsNone(pricing.pk)
|
|
|
|
self.part.active = False
|
|
self.part.save()
|
|
|
|
self.part.delete()
|
|
|
|
# Check that part was actually deleted
|
|
with self.assertRaises(part.models.Part.DoesNotExist):
|
|
self.part.refresh_from_db()
|
|
|
|
def test_check_missing_pricing(self):
|
|
"""Tests for check_missing_pricing background task
|
|
|
|
Calling the check_missing_pricing task should:
|
|
- Create PartPricing objects where there are none
|
|
- Schedule pricing calculations for the newly created PartPricing objects
|
|
"""
|
|
|
|
from part.tasks import check_missing_pricing
|
|
|
|
# Create some parts
|
|
for ii in range(100):
|
|
part.models.Part.objects.create(
|
|
name=f"Part_{ii}",
|
|
description="A test part",
|
|
)
|
|
|
|
# Ensure there is no pricing data
|
|
part.models.PartPricing.objects.all().delete()
|
|
|
|
check_missing_pricing()
|
|
|
|
# Check that PartPricing objects have been created
|
|
self.assertEqual(part.models.PartPricing.objects.count(), 101)
|
|
|
|
def test_delete_part_with_stock_items(self):
|
|
"""Test deleting a part instance with stock items.
|
|
|
|
This is to test a specific edge condition which was discovered that caused an IntegrityError.
|
|
Ref: https://github.com/inventree/InvenTree/issues/4419
|
|
|
|
Essentially a series of on_delete listeners caused a new PartPricing object to be created,
|
|
but it pointed to a Part instance which was slated to be deleted inside an atomic transaction.
|
|
"""
|
|
|
|
p = part.models.Part.objects.create(
|
|
name="my part",
|
|
description="my part description",
|
|
active=False,
|
|
)
|
|
|
|
# Create some stock items
|
|
for _idx in range(3):
|
|
stock.models.StockItem.objects.create(
|
|
part=p,
|
|
quantity=10,
|
|
purchase_price=Money(10, 'USD')
|
|
)
|
|
|
|
# Manually schedule a pricing update (does not happen automatically in testing)
|
|
p.schedule_pricing_update(create=True, test=True)
|
|
|
|
# Check that a PartPricing object exists
|
|
self.assertTrue(part.models.PartPricing.objects.filter(part=p).exists())
|
|
|
|
# Delete the part
|
|
p.delete()
|
|
|
|
# Check that the PartPricing object has been deleted
|
|
self.assertFalse(part.models.PartPricing.objects.filter(part=p).exists())
|
|
|
|
# Try to update pricing (should fail gracefully as the Part has been deleted)
|
|
p.schedule_pricing_update(create=False, test=True)
|
|
self.assertFalse(part.models.PartPricing.objects.filter(part=p).exists())
|