mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-16 12:05:53 +00:00
[Pricing] Add option to convert received items currency (#8970)
* Add new global setting * Convert to base currency on receipt * Fix total price rendering in PO table * Fix for tasks.py * Update .gitignore - Ignore auto-generated files * Update docs Improved documentation for pricing/currency support * Updates * Fix caching for default currency - Now managed better by session caching * Add unit test for new feature * Playwright test fixes * Validate copying of media files * Validate media files * Adjust playwright setup * Allow multiple attempts to fetch release information * Tweak unit tests * Revert changes to .gitignore file - Just trying stuff at this point * Add debug msg * Try hard-coded paths * Remove debug prints * Abs path for database * More debug * Fix typos * Revert change to db name * Remove debug statements (again) * Cleanup playwright tests * More test tweaks --------- Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
@ -150,7 +150,7 @@ def do_typecast(value, type, var_name=None):
|
||||
var_name: Name that should be logged e.g. 'INVENTREE_STATIC_ROOT'. Set if logging is required.
|
||||
|
||||
Returns:
|
||||
Typecasted value or original value if typecasting failed.
|
||||
Typecast value or original value if typecasting failed.
|
||||
"""
|
||||
# Force 'list' of strings
|
||||
if type is list:
|
||||
|
@ -9,7 +9,6 @@ from django.conf import settings
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import rest_framework.views as drfviews
|
||||
import structlog
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError as DRFValidationError
|
||||
@ -77,6 +76,8 @@ def exception_handler(exc, context):
|
||||
|
||||
If sentry error reporting is enabled, we will also provide the original exception to sentry.io
|
||||
"""
|
||||
import rest_framework.views as drfviews
|
||||
|
||||
import InvenTree.sentry
|
||||
|
||||
response = None
|
||||
|
@ -33,7 +33,7 @@ class InvenTreeRestURLField(RestURLField):
|
||||
self.validators[-1].schemes = allowable_url_schemes()
|
||||
|
||||
def run_validation(self, data=empty):
|
||||
"""Override default validation behaviour for this field type."""
|
||||
"""Override default validation behavior for this field type."""
|
||||
strict_urls = get_global_setting('INVENTREE_STRICT_URLS', cache=False)
|
||||
|
||||
if not strict_urls and data is not empty and '://' not in data:
|
||||
|
@ -92,7 +92,7 @@ class InvenTreeOrderingFilter(filters.OrderingFilter):
|
||||
|
||||
Then, specify a ordering_field_aliases attribute:
|
||||
|
||||
ordering_field_alises = {
|
||||
ordering_field_aliases = {
|
||||
'name': 'part__part__name',
|
||||
'SKU': 'part__SKU',
|
||||
}
|
||||
|
@ -157,7 +157,7 @@ class CustomAllauthTwoFactorMiddleware(AllauthTwoFactorMiddleware):
|
||||
"""This function ensures only frontend code triggers the MFA auth cycle."""
|
||||
|
||||
def process_request(self, request):
|
||||
"""Check if requested url is forntend and enforce MFA check."""
|
||||
"""Check if requested url is frontend and enforce MFA check."""
|
||||
try:
|
||||
if not url_matcher.resolve(request.path[1:]):
|
||||
super().process_request(request)
|
||||
|
@ -96,7 +96,7 @@ class CleanMixin:
|
||||
def clean_data(self, data: dict) -> dict:
|
||||
"""Clean / sanitize data.
|
||||
|
||||
This uses mozillas bleach under the hood to disable certain html tags by
|
||||
This uses Mozilla's bleach under the hood to disable certain html tags by
|
||||
encoding them - this leads to script tags etc. to not work.
|
||||
The results can be longer then the input; might make some character combinations
|
||||
`ugly`. Prevents XSS on the server-level.
|
||||
|
@ -16,7 +16,7 @@ def get_model_for_view(view):
|
||||
return view.serializer_class.Meta.model
|
||||
|
||||
if hasattr(view, 'get_serializer_class'):
|
||||
return view.get_serializr_class().Meta.model
|
||||
return view.get_serializer_class().Meta.model
|
||||
|
||||
raise AttributeError(f'Serializer class not specified for {view.__class__}')
|
||||
|
||||
|
@ -4,7 +4,6 @@ import decimal
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@ -20,14 +19,6 @@ def currency_code_default():
|
||||
"""Returns the default currency code (or USD if not specified)."""
|
||||
from common.settings import get_global_setting
|
||||
|
||||
try:
|
||||
cached_value = cache.get('currency_code_default', '')
|
||||
except Exception:
|
||||
cached_value = None
|
||||
|
||||
if cached_value:
|
||||
return cached_value
|
||||
|
||||
try:
|
||||
code = get_global_setting('INVENTREE_DEFAULT_CURRENCY', create=True, cache=True)
|
||||
except Exception: # pragma: no cover
|
||||
@ -37,12 +28,6 @@ def currency_code_default():
|
||||
if code not in CURRENCIES:
|
||||
code = 'USD' # pragma: no cover
|
||||
|
||||
# Cache the value for a short amount of time
|
||||
try:
|
||||
cache.set('currency_code_default', code, 30)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return code
|
||||
|
||||
|
||||
|
@ -834,6 +834,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'PURCHASEORDER_CONVERT_CURRENCY': {
|
||||
'name': _('Convert Currency'),
|
||||
'description': _('Convert item value to base currency when receiving stock'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
'PURCHASEORDER_AUTO_COMPLETE': {
|
||||
'name': _('Auto Complete Purchase Orders'),
|
||||
'description': _(
|
||||
|
@ -881,7 +881,19 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
# Calculate unit purchase price (in base units)
|
||||
if line.purchase_price:
|
||||
unit_purchase_price = line.purchase_price
|
||||
|
||||
# Convert purchase price to base units
|
||||
unit_purchase_price /= line.part.base_quantity(1)
|
||||
|
||||
# Convert to base currency
|
||||
if get_global_setting('PURCHASEORDER_CONVERT_CURRENCY'):
|
||||
try:
|
||||
unit_purchase_price = convert_money(
|
||||
unit_purchase_price, currency_code_default()
|
||||
)
|
||||
except Exception:
|
||||
log_error('PurchaseOrder.receive_line_item')
|
||||
|
||||
else:
|
||||
unit_purchase_price = None
|
||||
|
||||
|
@ -12,7 +12,9 @@ from djmoney.money import Money
|
||||
|
||||
import common.models
|
||||
import order.tasks
|
||||
from common.settings import get_global_setting, set_global_setting
|
||||
from company.models import Company, SupplierPart
|
||||
from InvenTree.unit_test import ExchangeRateMixin
|
||||
from order.status_codes import PurchaseOrderStatus
|
||||
from part.models import Part
|
||||
from stock.models import StockItem, StockLocation
|
||||
@ -21,7 +23,7 @@ from users.models import Owner
|
||||
from .models import PurchaseOrder, PurchaseOrderExtraLine, PurchaseOrderLineItem
|
||||
|
||||
|
||||
class OrderTest(TestCase):
|
||||
class OrderTest(TestCase, ExchangeRateMixin):
|
||||
"""Tests to ensure that the order models are functioning correctly."""
|
||||
|
||||
fixtures = [
|
||||
@ -305,6 +307,44 @@ class OrderTest(TestCase):
|
||||
self.assertEqual(si.quantity, 0.5)
|
||||
self.assertEqual(si.purchase_price, Money(100, 'USD'))
|
||||
|
||||
def test_receive_convert_currency(self):
|
||||
"""Test receiving orders with different currencies."""
|
||||
# Setup some dummy exchange rates
|
||||
self.generate_exchange_rates()
|
||||
|
||||
set_global_setting('INVENTREE_DEFAULT_CURRENCY', 'USD')
|
||||
self.assertEqual(get_global_setting('INVENTREE_DEFAULT_CURRENCY'), 'USD')
|
||||
|
||||
# Enable auto conversion
|
||||
set_global_setting('PURCHASEORDER_CONVERT_CURRENCY', True)
|
||||
|
||||
order = PurchaseOrder.objects.get(pk=7)
|
||||
sku = SupplierPart.objects.get(SKU='ZERGM312')
|
||||
loc = StockLocation.objects.get(id=1)
|
||||
|
||||
# Add a line item (in CAD)
|
||||
line = order.add_line_item(sku, 100, purchase_price=Money(1.25, 'CAD'))
|
||||
|
||||
order.place_order()
|
||||
|
||||
# Receive a line item, should be converted to GBP
|
||||
order.receive_line_item(line, loc, 50, user=None)
|
||||
item = order.stock_items.order_by('-pk').first()
|
||||
|
||||
self.assertEqual(item.quantity, 50)
|
||||
self.assertEqual(item.purchase_price_currency, 'USD')
|
||||
self.assertAlmostEqual(item.purchase_price.amount, Decimal(0.7353), 3)
|
||||
|
||||
# Disable auto conversion
|
||||
set_global_setting('PURCHASEORDER_CONVERT_CURRENCY', False)
|
||||
|
||||
order.receive_line_item(line, loc, 30, user=None)
|
||||
item = order.stock_items.order_by('-pk').first()
|
||||
|
||||
self.assertEqual(item.quantity, 30)
|
||||
self.assertEqual(item.purchase_price_currency, 'CAD')
|
||||
self.assertAlmostEqual(item.purchase_price.amount, Decimal(1.25), 3)
|
||||
|
||||
def test_overdue_notification(self):
|
||||
"""Test overdue purchase order notification.
|
||||
|
||||
|
@ -12,6 +12,7 @@ from django.db.utils import IntegrityError, OperationalError, ProgrammingError
|
||||
|
||||
from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode
|
||||
|
||||
import InvenTree.exceptions
|
||||
import InvenTree.ready
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
@ -141,7 +142,7 @@ class ReportConfig(AppConfig):
|
||||
)
|
||||
logger.info("Creating new label template: '%s'", template['name'])
|
||||
except Exception:
|
||||
pass
|
||||
InvenTree.exceptions.log_error('create_default_labels')
|
||||
|
||||
def create_default_reports(self):
|
||||
"""Create default report templates."""
|
||||
@ -232,4 +233,4 @@ class ReportConfig(AppConfig):
|
||||
)
|
||||
logger.info("Created new report template: '%s'", template['name'])
|
||||
except Exception:
|
||||
pass
|
||||
InvenTree.exceptions.log_error('create_default_reports')
|
||||
|
Reference in New Issue
Block a user