2
0
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:
Oliver
2025-02-03 18:34:15 +11:00
committed by GitHub
parent a760d00c96
commit d363c408f8
29 changed files with 250 additions and 134 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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__}')

View File

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

View File

@ -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': _(

View File

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

View File

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

View File

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