2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-16 03:55:41 +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')

View File

@ -252,6 +252,7 @@ export default function SystemSettings() {
keys={[
'PURCHASEORDER_REFERENCE_PATTERN',
'PURCHASEORDER_REQUIRE_RESPONSIBLE',
'PURCHASEORDER_CONVERT_CURRENCY',
'PURCHASEORDER_EDIT_COMPLETED_ORDERS',
'PURCHASEORDER_AUTO_COMPLETE'
]}

View File

@ -32,8 +32,7 @@ import {
NoteColumn,
PartColumn,
ReferenceColumn,
TargetDateColumn,
TotalPriceColumn
TargetDateColumn
} from '../ColumnRenderers';
import type { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
@ -226,7 +225,11 @@ export function PurchaseOrderLineItemTable({
accessor: 'purchase_price',
title: t`Unit Price`
}),
TotalPriceColumn(),
CurrencyColumn({
accessor: 'total_price',
currency_accessor: 'purchase_price_currency',
title: t`Total Price`
}),
TargetDateColumn({}),
{
accessor: 'destination',

View File

@ -10,10 +10,11 @@ import { doQuickLogin } from '../login.ts';
test('Build Order - Basic Tests', async ({ page }) => {
await doQuickLogin(page);
await navigate(page, 'part/');
// Navigate to the correct build order
await page.getByRole('tab', { name: 'Manufacturing', exact: true }).click();
await page.getByRole('tab', { name: 'Build Orders', exact: true }).click();
await clearTableFilters(page);
// We have now loaded the "Build Order" table. Check for some expected texts
await page.getByText('On Hold').first().waitFor();
@ -120,6 +121,8 @@ test('Build Order - Build Outputs', async ({ page }) => {
await navigate(page, 'manufacturing/index/');
await page.getByRole('tab', { name: 'Build Orders', exact: true }).click();
await clearTableFilters(page);
// We have now loaded the "Build Order" table. Check for some expected texts
await page.getByText('On Hold').first().waitFor();
await page.getByText('Pending').first().waitFor();

View File

@ -122,6 +122,7 @@ test('Parts - Allocations', async ({ page }) => {
// Navigate to the "Allocations" tab
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
await page.getByRole('tab', { name: 'Allocations' }).click();

View File

@ -63,13 +63,9 @@ test('Sales Orders - Tabs', async ({ page }) => {
test('Sales Orders - Basic Tests', async ({ page }) => {
await doQuickLogin(page);
await navigate(page, 'home');
await page.getByRole('tab', { name: 'Sales' }).click();
await page.getByRole('tab', { name: 'Sales Orders' }).click();
// Check for expected text in the table
await page.getByRole('tab', { name: 'Sales Orders' }).waitFor();
await clearTableFilters(page);
await setTableChoiceFilter(page, 'status', 'On Hold');
@ -106,7 +102,6 @@ test('Sales Orders - Basic Tests', async ({ page }) => {
test('Sales Orders - Shipments', async ({ page }) => {
await doQuickLogin(page);
await navigate(page, 'home');
await page.getByRole('tab', { name: 'Sales' }).click();
await page.getByRole('tab', { name: 'Sales Orders' }).click();

View File

@ -60,6 +60,8 @@ test('Report Printing', async ({ page }) => {
// Navigate to a specific PurchaseOrder
await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
await page.getByRole('cell', { name: 'PO0009' }).click();
// Select "print report"

View File

@ -95,10 +95,14 @@ test('Settings - Admin', async ({ page }) => {
await page.getByLabel('row-action-menu-0').click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await expect(page.getByLabel('text-field-name')).toHaveValue('Room');
await expect(page.getByLabel('text-field-description')).toHaveValue('A room');
await page.getByLabel('text-field-name').fill('Large Room');
await page.waitForTimeout(500);
await page.getByLabel('text-field-description').fill('A large room');
// Toggle the "description" field
const oldDescription = await page
.getByLabel('text-field-description')
.inputValue();
const newDescription = `${oldDescription} (edited)`;
await page.getByLabel('text-field-description').fill(newDescription);
await page.waitForTimeout(500);
await page.getByRole('button', { name: 'Submit' }).click();
@ -114,13 +118,9 @@ test('Settings - Admin', async ({ page }) => {
// Edit first item again (revert values)
await page.getByLabel('row-action-menu-0').click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await expect(page.getByLabel('text-field-name')).toHaveValue('Large Room');
await expect(page.getByLabel('text-field-description')).toHaveValue(
'A large room'
);
await page.getByLabel('text-field-name').fill('Room');
await page.waitForTimeout(500);
await page.getByLabel('text-field-description').fill('A room');
await page.getByLabel('text-field-description').fill(oldDescription);
await page.waitForTimeout(500);
await page.getByRole('button', { name: 'Submit' }).click();
});