diff --git a/docs/docs/settings/global.md b/docs/docs/settings/global.md index 2efc2482e3f9..0cdfb66f78db 100644 --- a/docs/docs/settings/global.md +++ b/docs/docs/settings/global.md @@ -108,6 +108,7 @@ Configuration of pricing data and currency support: {{ globalsetting("CURRENCY_CODES") }} {{ globalsetting("CURRENCY_UPDATE_PLUGIN") }} {{ globalsetting("CURRENCY_UPDATE_INTERVAL") }} +{{ globalsetting("PRICING_PLUGIN") }} {{ globalsetting("PRICING_DECIMAL_PLACES_MIN") }} {{ globalsetting("PRICING_DECIMAL_PLACES") }} {{ globalsetting("PRICING_AUTO_UPDATE") }} diff --git a/src/backend/InvenTree/InvenTree/helpers.py b/src/backend/InvenTree/InvenTree/helpers.py index 18bc1a6a2010..842f3ff36a9a 100644 --- a/src/backend/InvenTree/InvenTree/helpers.py +++ b/src/backend/InvenTree/InvenTree/helpers.py @@ -29,8 +29,6 @@ from djmoney.money import Money from PIL import Image -from common.currency import currency_code_default - from .settings import MEDIA_URL, STATIC_URL logger = structlog.get_logger('inventree') @@ -399,7 +397,7 @@ def decimal2string(d): return s.rstrip('0').rstrip('.') -def decimal2money(d, currency=None): +def decimal2money(d, currency: Optional[str] = None) -> Money: """Format a Decimal number as Money. Args: @@ -409,12 +407,12 @@ def decimal2money(d, currency=None): Returns: A Money object from the input(s) """ - if not currency: - currency = currency_code_default() - return Money(d, currency) + from common.currency import currency_code_default + + return Money(d, currency or currency_code_default()) -def WrapWithQuotes(text, quote='"'): +def WrapWithQuotes(text: str, quote: str = '"') -> str: """Wrap the supplied text with quotes. Args: @@ -424,6 +422,8 @@ def WrapWithQuotes(text, quote='"'): Returns: Supplied text wrapped in quote char """ + text = str(text) + if not text.startswith(quote): text = quote + text diff --git a/src/backend/InvenTree/common/currency.py b/src/backend/InvenTree/common/currency.py index 307e54d6d526..5578c0fb0b86 100644 --- a/src/backend/InvenTree/common/currency.py +++ b/src/backend/InvenTree/common/currency.py @@ -8,6 +8,9 @@ from django.utils.translation import gettext_lazy as _ import structlog +from djmoney.contrib.exchange.exceptions import MissingRate +from djmoney.contrib.exchange.models import convert_money +from djmoney.money import Money from moneyed import CURRENCIES import InvenTree.helpers @@ -131,19 +134,41 @@ def validate_currency_codes(value): return list(valid_currencies) -def currency_exchange_plugins() -> Optional[list]: - """Return a list of plugin choices which can be used for currency exchange.""" - try: - from plugin import PluginMixinEnum, registry +def convert_currency( + money: Money, currency: Optional[str] = None, raise_error: bool = False +) -> Money: + """Convert a Money object to the specified currency. - plugs = registry.with_mixin(PluginMixinEnum.CURRENCY_EXCHANGE, active=True) - except Exception: - plugs = [] + Arguments: + money: The Money object to convert + currency: The target currency code (e.g. 'USD'). + raise_error: If True, raise an exception if conversion fails. - if len(plugs) == 0: + If no currency is specified, convert to the default currency. + """ + if money is None: return None - return [('', _('No plugin'))] + [(plug.slug, plug.human_name) for plug in plugs] + if currency is None: + currency = currency_code_default() + + target_currency = currency_code_default() + + try: + result = convert_money(money, target_currency) + except MissingRate as exc: + logger.warning( + 'No currency conversion rate available for %s -> %s', + money.currency, + target_currency, + ) + + if raise_error: + raise exc + + result = None + + return result def get_price( diff --git a/src/backend/InvenTree/common/pricing.py b/src/backend/InvenTree/common/pricing.py new file mode 100644 index 000000000000..dd216d2d70ec --- /dev/null +++ b/src/backend/InvenTree/common/pricing.py @@ -0,0 +1,38 @@ +"""Helper functions for pricing support.""" + +from dataclasses import dataclass + +from djmoney.money import Money + +from common.settings import get_global_setting +from plugin import PluginMixinEnum, registry +from plugin.models import InvenTreePlugin + + +@dataclass +class PriceRangeTuple: + """Dataclass representing a price range.""" + + min: Money + max: Money + + +def get_pricing_plugin() -> InvenTreePlugin: + """Return the selected pricing plugin. + + Attempts to retrieve the currently selected pricing plugin from the plugin registry. + If the plugin is not available, or is disabled, + then return the default InvenTreePricing plugin. + """ + default_slug = 'inventree-pricing' + + plugin_slug = get_global_setting('PRICING_PLUGIN', backup_value=default_slug) + + plugin = registry.get_plugin(plugin_slug, with_mixin=PluginMixinEnum.PRICING) + + if plugin is None: + plugin = registry.get_plugin(default_slug, with_mixin=PluginMixinEnum.PRICING) + + # TODO: Handle case where default plugin is missing? + + return plugin diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py index 066c8614cba8..65f703186108 100644 --- a/src/backend/InvenTree/common/setting/system.py +++ b/src/backend/InvenTree/common/setting/system.py @@ -4,6 +4,7 @@ import os import re import uuid +from typing import Optional from django.conf import settings as django_settings from django.contrib.auth.models import Group @@ -15,7 +16,6 @@ import build.validators import common.currency -import common.models import common.validators import order.validators import report.helpers @@ -107,18 +107,28 @@ def reload_plugin_registry(setting): registry.reload_plugins(full_reload=True, force_reload=True, collect=True) -def barcode_plugins() -> list: +def currency_exchange_plugins() -> Optional[list]: + """Return a list of plugin choices which can be used for currency exchange.""" + from plugin import PluginMixinEnum + from plugin.registry import get_plugin_options + + return get_plugin_options(PluginMixinEnum.CURRENCY_EXCHANGE, allow_null=True) + + +def barcode_plugins() -> Optional[list]: """Return a list of plugin choices which can be used for barcode generation.""" - try: - from plugin import PluginMixinEnum, registry + from plugin import PluginMixinEnum + from plugin.registry import get_plugin_options - plugins = registry.with_mixin(PluginMixinEnum.BARCODE, active=True) - except Exception: # pragma: no cover - plugins = [] + return get_plugin_options(PluginMixinEnum.BARCODE, allow_null=False) - return [ - (plug.slug, plug.human_name) for plug in plugins if plug.has_barcode_generation - ] + +def pricing_plugins() -> Optional[list]: + """Return a list of plugin choices which can be used for pricing calculations.""" + from plugin import PluginMixinEnum + from plugin.registry import get_plugin_options + + return get_plugin_options(PluginMixinEnum.PRICING, allow_null=False) def default_uuid4() -> str: @@ -257,7 +267,7 @@ class SystemSetId: 'CURRENCY_UPDATE_PLUGIN': { 'name': _('Currency Update Plugin'), 'description': _('Currency update plugin to use'), - 'choices': common.currency.currency_exchange_plugins, + 'choices': currency_exchange_plugins, 'default': 'inventreecurrencyexchange', }, 'INVENTREE_DOWNLOAD_FROM_URL': { @@ -530,6 +540,12 @@ class SystemSetId: 'default': True, 'validator': bool, }, + 'PRICING_PLUGIN': { + 'name': _('Pricing Plugin'), + 'description': _('Plugin to use for pricing calculations'), + 'choices': pricing_plugins, + 'default': 'inventree-pricing', + }, 'PRICING_DECIMAL_PLACES_MIN': { 'name': _('Minimum Pricing Decimal Places'), 'description': _( diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index ab6d5e7944cb..a80db2981124 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -8,7 +8,6 @@ import math import os import re -from datetime import timedelta from decimal import Decimal, InvalidOperation from typing import Optional, cast @@ -41,7 +40,6 @@ import common.currency import common.models -import common.settings import InvenTree.conversion import InvenTree.fields import InvenTree.helpers @@ -56,6 +54,7 @@ from build.status_codes import BuildStatusGroups from common.currency import currency_code_default from common.icons import validate_icon +from common.pricing import get_pricing_plugin from common.settings import get_global_setting from company.models import SupplierPart from InvenTree import helpers, validators @@ -63,11 +62,8 @@ from InvenTree.fields import InvenTreeURLField from InvenTree.helpers import decimal2money, decimal2string, normalize, str2bool from order import models as OrderModels -from order.status_codes import ( - PurchaseOrderStatus, - PurchaseOrderStatusGroups, - SalesOrderStatusGroups, -) +from order.status_codes import PurchaseOrderStatusGroups, SalesOrderStatusGroups +from plugin.mixins import PricingMixin from stock import models as StockModels logger = structlog.get_logger('inventree') @@ -2736,28 +2732,6 @@ def is_valid(self): """Return True if the cached pricing is valid.""" return self.updated is not None - def convert(self, money): - """Attempt to convert money value to default currency. - - If a MissingRate error is raised, ignore it and return None - """ - if money is None: - return None - - target_currency = currency_code_default() - - try: - result = convert_money(money, target_currency) - except MissingRate: - logger.warning( - 'No currency conversion rate available for %s -> %s', - money.currency, - target_currency, - ) - result = None - - return result - def schedule_for_update(self, counter: int = 0, refresh: bool = True): """Schedule this pricing to be updated. @@ -2978,8 +2952,12 @@ def update_bom_cost(self, save=True): sub_part_pricing = sub_part.pricing - sub_part_min = self.convert(sub_part_pricing.overall_min) - sub_part_max = self.convert(sub_part_pricing.overall_max) + sub_part_min = common.currency.convert_currency( + sub_part_pricing.overall_min + ) + sub_part_max = common.currency.convert_currency( + sub_part_pricing.overall_max + ) if sub_part_min is not None: if bom_item_min is None or sub_part_min < bom_item_min: @@ -2992,13 +2970,13 @@ def update_bom_cost(self, save=True): # Update cumulative totals if bom_item_min is not None: bom_item_min *= bom_item.quantity - cumulative_min += self.convert(bom_item_min) + cumulative_min += common.currency.convert_currency(bom_item_min) any_min_elements = True if bom_item_max is not None: bom_item_max *= bom_item.quantity - cumulative_max += self.convert(bom_item_max) + cumulative_max += common.currency.convert_currency(bom_item_max) any_max_elements = True @@ -3015,164 +2993,81 @@ def update_bom_cost(self, save=True): if save: self.save() - def update_purchase_cost(self, save=True): + def update_purchase_cost(self, save: bool = True): """Recalculate historical purchase cost for the referenced Part instance. Purchase history only takes into account "completed" purchase orders. """ - # Find all line items for completed orders which reference this part - line_items = OrderModels.PurchaseOrderLineItem.objects.filter( - order__status=PurchaseOrderStatus.COMPLETE.value, - received__gt=0, - part__part=self.part, - ) - - # Exclude line items which do not have an associated price - line_items = line_items.exclude(purchase_price=None) - - purchase_min = None - purchase_max = None - - for line in line_items: - if line.purchase_price is None: - continue - - # Take supplier part pack size into account - purchase_cost = self.convert( - line.purchase_price / line.part.pack_quantity_native - ) - - if purchase_cost is None: - continue + plugin: PricingMixin = get_pricing_plugin() + price_range = plugin.calculate_part_purchase_price_range(self.part) - if purchase_min is None or purchase_cost < purchase_min: - purchase_min = purchase_cost + self.purchase_cost_min = price_range.min + self.purchase_cost_max = price_range.max - if purchase_max is None or purchase_cost > purchase_max: - purchase_max = purchase_cost + if save: + self.save() + # TODO: Account for 'stock on hand' price range # Also check if manual stock item pricing is included - if get_global_setting('PRICING_USE_STOCK_PRICING', True): - items = self.part.stock_items.all() + # if get_global_setting('PRICING_USE_STOCK_PRICING', True): + # items = self.part.stock_items.all() - # Limit to stock items updated within a certain window - days = int(get_global_setting('PRICING_STOCK_ITEM_AGE_DAYS', 0)) + # # Limit to stock items updated within a certain window + # days = int(get_global_setting('PRICING_STOCK_ITEM_AGE_DAYS', 0)) - if days > 0: - date_threshold = InvenTree.helpers.current_date() - timedelta(days=days) - items = items.filter(updated__gte=date_threshold) + # if days > 0: + # date_threshold = InvenTree.helpers.current_date() - timedelta(days=days) + # items = items.filter(updated__gte=date_threshold) - for item in items: - cost = self.convert(item.purchase_price) - - # Skip if the cost could not be converted (for some reason) - if cost is None: - continue + # for item in items: + # cost = common.currency.convert_currency(item.purchase_price) - if purchase_min is None or cost < purchase_min: - purchase_min = cost + # # Skip if the cost could not be converted (for some reason) + # if cost is None: + # continue - if purchase_max is None or cost > purchase_max: - purchase_max = cost + # if purchase_min is None or cost < purchase_min: + # purchase_min = cost - self.purchase_cost_min = purchase_min - self.purchase_cost_max = purchase_max - - if save: - self.save() + # if purchase_max is None or cost > purchase_max: + # purchase_max = cost - def update_internal_cost(self, save=True): + def update_internal_cost(self, save: bool = True): """Recalculate internal cost for the referenced Part instance.""" - min_int_cost = None - max_int_cost = None + plugin: PricingMixin = get_pricing_plugin() + price_range = plugin.calculate_part_internal_price_range(self.part) - if get_global_setting('PART_INTERNAL_PRICE', False): - # Only calculate internal pricing if internal pricing is enabled - for pb in self.part.internalpricebreaks.all(): - cost = self.convert(pb.price) - - if cost is None: - # Ignore if cost could not be converted for some reason - continue - - if min_int_cost is None or cost < min_int_cost: - min_int_cost = cost - - if max_int_cost is None or cost > max_int_cost: - max_int_cost = cost - - self.internal_cost_min = min_int_cost - self.internal_cost_max = max_int_cost + self.internal_cost_min = price_range.min + self.internal_cost_max = price_range.max if save: self.save() - def update_supplier_cost(self, save=True): + def update_supplier_cost(self, save: bool = True): """Recalculate supplier cost for the referenced Part instance. - The limits are simply the lower and upper bounds of available SupplierPriceBreaks - We do not take "quantity" into account here """ - min_sup_cost = None - max_sup_cost = None - - if self.part.purchaseable: - # Iterate through each available SupplierPart instance - for sp in self.part.supplier_parts.all(): - # Iterate through each available SupplierPriceBreak instance - for pb in sp.pricebreaks.all(): - if pb.price is None: - continue + plugin: PricingMixin = get_pricing_plugin() + price_range = plugin.calculate_part_supplier_price_range(self.part) - # Ensure we take supplier part pack size into account - cost = self.convert(pb.price / sp.pack_quantity_native) - - if cost is None: - continue - - if min_sup_cost is None or cost < min_sup_cost: - min_sup_cost = cost - - if max_sup_cost is None or cost > max_sup_cost: - max_sup_cost = cost - - self.supplier_price_min = min_sup_cost - self.supplier_price_max = max_sup_cost + self.supplier_price_min = price_range.min + self.supplier_price_max = price_range.max if save: self.save() - def update_variant_cost(self, save=True): + def update_variant_cost(self, save: bool = True): """Update variant cost values. Here we track the min/max costs of any variant parts. """ - variant_min = None - variant_max = None - - active_only = get_global_setting('PRICING_ACTIVE_VARIANTS', False) - - if self.part.is_template: - variants = self.part.get_descendants(include_self=False) - - for v in variants: - if active_only and not v.active: - # Ignore inactive variant parts - continue - - v_min = self.convert(v.pricing.overall_min) - v_max = self.convert(v.pricing.overall_max) - - if v_min is not None: - if variant_min is None or v_min < variant_min: - variant_min = v_min - - if v_max is not None: - if variant_max is None or v_max > variant_max: - variant_max = v_max + plugin: PricingMixin = get_pricing_plugin() + price_range = plugin.calculate_part_variant_price_range(self.part) - self.variant_cost_min = variant_min - self.variant_cost_max = variant_max + self.variant_cost_min = price_range.min + self.variant_cost_max = price_range.max if save: self.save() @@ -3212,7 +3107,7 @@ def update_overall_cost(self): continue # Ensure we are working in a common currency - cost = self.convert(cost) + cost = common.currency.convert_currency(cost) if overall_min is None or cost < overall_min: overall_min = cost @@ -3223,7 +3118,7 @@ def update_overall_cost(self): continue # Ensure we are working in a common currency - cost = self.convert(cost) + cost = common.currency.convert_currency(cost) if overall_max is None or cost > overall_max: overall_max = cost @@ -3237,12 +3132,12 @@ def update_overall_cost(self): overall_max = self.internal_cost_max if self.override_min is not None: - overall_min = self.convert(self.override_min) + overall_min = common.currency.convert_currency(self.override_min) self.overall_min = overall_min if self.override_max is not None: - overall_max = self.convert(self.override_max) + overall_max = common.currency.convert_currency(self.override_max) self.overall_max = overall_max @@ -3253,7 +3148,7 @@ def update_sale_cost(self, save=True): max_sell_price = None for pb in self.part.salepricebreaks.all(): - cost = self.convert(pb.price) + cost = common.currency.convert_currency(pb.price) if cost is None: continue @@ -3283,7 +3178,7 @@ def update_sale_cost(self, save=True): line_items = line_items.exclude(sale_price=None) for line in line_items: - cost = self.convert(line.sale_price) + cost = common.currency.convert_currency(line.sale_price) if cost is None: continue diff --git a/src/backend/InvenTree/plugin/base/integration/PricingMixin.py b/src/backend/InvenTree/plugin/base/integration/PricingMixin.py new file mode 100644 index 000000000000..7fb099164647 --- /dev/null +++ b/src/backend/InvenTree/plugin/base/integration/PricingMixin.py @@ -0,0 +1,139 @@ +"""Pricing mixin class for supporting pricing data.""" + +from common.pricing import PriceRangeTuple +from plugin import PluginMixinEnum +from plugin.helpers import MixinNotImplementedError + + +class PricingMixin: + """Mixin class which provides support for pricing functionality. + + - This plugin class provides stubs for pricing-related features. + - A default implementation is provided which can be overridden by the plugin. + """ + + class MixinMeta: + """Meta options for this mixin class.""" + + MIXIN_NAME = 'Pricing' + + def __init__(self): + """Register the mixin.""" + super().__init__() + self.add_mixin(PluginMixinEnum.PRICING, True, __class__) + + def calculate_part_overall_price_range( + self, part, *args, **kwargs + ) -> PriceRangeTuple: + """Calculate the overall price for a given part. + + Arguments: + part: The part instance for which to calculate the price. + *args, **kwargs: Additional arguments for price calculation. + + Returns: + A tuple representing the min, max price range for the part + + The default implementation calculates the price range based on + the overall price range generated by the part's pricing data. + """ + raise MixinNotImplementedError( + 'calculate_part_overall_price_range not implemented' + ) + + def calculate_part_bom_price_range(self, part, *args, **kwargs) -> PriceRangeTuple: + """Calculate the assembly price range for a given part. + + Arguments: + part: The part instance for which to calculate the BOM price. + *args, **kwargs: Additional arguments for BOM price calculation. + + Returns: + A tuple representing the min, max BOM price range for the part + + The "BOM price range" is the cost of manufacturing the assembly + from its constituent parts, based on their pricing data. + """ + raise MixinNotImplementedError('calculate_part_bom_price_range not implemented') + + def calculate_part_purchase_price_range( + self, part, *args, **kwargs + ) -> PriceRangeTuple: + """Calculate the purchase price range for a given part. + + Arguments: + part: The part instance for which to calculate the purchase price. + *args, **kwargs: Additional arguments for purchase price calculation. + + Returns: + A tuple representing the min, max purchase price range for the part + + The "purchase price range" is the cost to purchase the part from suppliers, + """ + raise MixinNotImplementedError( + 'calculate_part_purchase_price_range not implemented' + ) + + def calculate_part_supplier_price_range( + self, part, *args, **kwargs + ) -> PriceRangeTuple: + """Calculate the supplier price range for a given part. + + Arguments: + part: The part instance for which to calculate the supplier price. + *args, **kwargs: Additional arguments for supplier price calculation. + + Returns: + A tuple representing the min, max supplier price range for the part + """ + raise MixinNotImplementedError( + 'calculate_part_supplier_price_range not implemented' + ) + + def calculate_part_internal_price_range( + self, part, *args, **kwargs + ) -> PriceRangeTuple: + """Calculate the internal price range for a given part. + + Arguments: + part: The part instance for which to calculate the internal price. + *args, **kwargs: Additional arguments for internal price calculation. + + Returns: + A tuple representing the min, max internal price range for the part + """ + raise MixinNotImplementedError( + 'calculate_part_internal_price_range not implemented' + ) + + def calculate_part_variant_price_range( + self, part, *args, **kwargs + ) -> PriceRangeTuple: + """Calculate the variant price range for a given part. + + Arguments: + part: The part instance for which to calculate the variant price. + *args, **kwargs: Additional arguments for variant price calculation. + + Returns: + A tuple representing the min, max variant price range for the part + + The "variant price range" is the price range for all variants of a template part + """ + raise MixinNotImplementedError( + 'calculate_part_variant_price_range not implemented' + ) + + def calculate_part_sale_price_range(self, part, *args, **kwargs) -> PriceRangeTuple: + """Calculate the sale price range for a given part. + + Arguments: + part: The part instance for which to calculate the sale price. + *args, **kwargs: Additional arguments for sale price calculation. + + Returns: + A tuple representing the min, max sale price range for the part + """ + raise MixinNotImplementedError( + 'calculate_part_sale_price_range not implemented' + ) diff --git a/src/backend/InvenTree/plugin/builtin/integration/inventree_pricing.py b/src/backend/InvenTree/plugin/builtin/integration/inventree_pricing.py new file mode 100644 index 000000000000..2a934680da13 --- /dev/null +++ b/src/backend/InvenTree/plugin/builtin/integration/inventree_pricing.py @@ -0,0 +1,300 @@ +"""Builtin plugin for providing pricing functionality.""" + +from django.core.validators import MinValueValidator +from django.utils.translation import gettext_lazy as _ + +import structlog +from djmoney.money import Money + +import order.models as order_models +import order.status_codes as order_status_codes +from common.currency import convert_currency +from common.pricing import PriceRangeTuple +from plugin import InvenTreePlugin +from plugin.mixins import PricingMixin, SettingsMixin + +logger = structlog.get_logger('inventree') + + +class InvenTreePricingPlugin(PricingMixin, SettingsMixin, InvenTreePlugin): + """Default InvenTree plugin for pricing functionality.""" + + NAME = 'InvenTreePricingPlugin' + SLUG = 'inventree-pricing' + AUTHOR = _('InvenTree contributors') + TITLE = _('InvenTree Pricing Plugin') + DESCRIPTION = _('Provides default pricing integration functionality for InvenTree') + VERSION = '1.0.0' + + # Plugin settings which can be configured to adjust pricing behavior + SETTINGS = { + 'PRICING_USE_SUPPLIER_PRICING': { + 'name': _('Use Supplier Pricing'), + 'description': _( + 'Include supplier price breaks in overall pricing calculations' + ), + 'default': True, + 'validator': bool, + }, + 'PRICING_PURCHASE_HISTORY_OVERRIDES_SUPPLIER': { + 'name': _('Purchase History Override'), + 'description': _( + 'Historical purchase order pricing overrides supplier price breaks' + ), + 'default': False, + 'validator': bool, + }, + 'PRICING_USE_STOCK_PRICING': { + 'name': _('Use Stock Item Pricing'), + 'description': _( + 'Use pricing from manually entered stock data for pricing calculations' + ), + 'default': True, + 'validator': bool, + }, + 'PRICING_STOCK_ITEM_AGE_DAYS': { + 'name': _('Stock Item Pricing Age'), + 'description': _( + 'Exclude stock items older than this number of days from pricing calculations' + ), + 'default': 0, + 'units': _('days'), + 'validator': [int, MinValueValidator(0)], + }, + 'PRICING_USE_VARIANT_PRICING': { + 'name': _('Use Variant Pricing'), + 'description': _('Include variant pricing in overall pricing calculations'), + 'default': True, + 'validator': bool, + }, + 'PRICING_ACTIVE_VARIANTS': { + 'name': _('Active Variants Only'), + 'description': _( + 'Only use active variant parts for calculating variant pricing' + ), + 'default': True, + 'validator': bool, + }, + 'PRICING_USE_INTERNAL_PRICE': { + 'name': _('Internal Price Override'), + 'description': _( + 'If available, internal prices override price range calculations' + ), + 'default': False, + 'validator': bool, + }, + } + + def calculate_part_overall_price_range( + self, part, *args, **kwargs + ) -> PriceRangeTuple: + """Calculate the overall price for a given part. + + Arguments: + part: The part instance for which to calculate the price. + *args, **kwargs: Additional arguments for price calculation. + + Returns: + A tuple representing the min, max price range for the part + + The default implementation calculates the price range based on + the overall price range generated by the part's pricing data. + """ + + def calculate_part_bom_price_range(self, part, *args, **kwargs) -> PriceRangeTuple: + """Calculate the assembly price range for a given part. + + Arguments: + part: The part instance for which to calculate the BOM price. + *args, **kwargs: Additional arguments for BOM price calculation. + + Returns: + A tuple representing the min, max BOM price range for the part + + The "BOM price range" is the cost of manufacturing the assembly + from its constituent parts, based on their pricing data. + """ + + def calculate_part_purchase_price_range( + self, part, *args, **kwargs + ) -> PriceRangeTuple: + """Calculate the purchase price range for a given part. + + Arguments: + part: The part instance for which to calculate the purchase price. + *args, **kwargs: Additional arguments for purchase price calculation. + + Returns: + A tuple representing the min, max purchase price range for the part + + The "purchase price range" is the cost to purchase the part from suppliers, + """ + # Find all line items for completed PurchaseOrders which reference this part + line_items = order_models.PurchaseOrderLineItem.objects.filter( + order__status__in=order_status_codes.PurchaseOrderStatusGroups.COMPLETE, + received__gt=0, + part__part=part, + ) + + # Exclude line items which do not have an associated price + line_items = line_items.exclude(purchase_price=None) + + line_items = line_items.prefetch_related('part', 'part__part') + + purchase_min: Money = None + purchase_max: Money = None + + # Iterate through all line items to determine min/max purchase price + for line in line_items: + if line.purchase_price is None: + continue + + # Account for supplier part pack size + purchase_cost = convert_currency( + line.purchase_price / line.part.pack_quantity_native + ) + + if purchase_cost is None: + continue + + if purchase_min is None or purchase_cost < purchase_min: + purchase_min = purchase_cost + + if purchase_max is None or purchase_cost > purchase_max: + purchase_max = purchase_cost + + return PriceRangeTuple(min=purchase_min, max=purchase_max) + + def calculate_part_supplier_price_range( + self, part, *args, **kwargs + ) -> PriceRangeTuple: + """Calculate the supplier price range for a given part. + + Arguments: + part: The part instance for which to calculate the supplier price. + *args, **kwargs: Additional arguments for supplier price calculation. + + Returns: + A tuple representing the min, max supplier price range for the part + """ + price_min: Money = None + price_max: Money = None + + # Ignore if the part is not marked as purchaseable + if not part.purchaseable: + return PriceRangeTuple(min=price_min, max=price_max) + + # Fetch supplier parts for this part + supplier_parts = part.supplier_parts.filter(active=True).prefetch_related( + 'pricebreaks' + ) + + # Iterate through each active supplier part + for supplier_part in supplier_parts.all(): + # Iterate through each price break for this supplier part + for price_break in supplier_part.pricebreaks.all(): + if price_break.price is None: + continue + + # Ensure that the supplier part pack size is taken into account + price = convert_currency( + price_break.price / supplier_part.pack_quantity_native + ) + + if price is None: + continue + + if price_min is None or price < price_min: + price_min = price + + if price_max is None or price > price_max: + price_max = price + + return PriceRangeTuple(min=price_min, max=price_max) + + def calculate_part_internal_price_range( + self, part, *args, **kwargs + ) -> PriceRangeTuple: + """Calculate the internal price range for a given part. + + Arguments: + part: The part instance for which to calculate the internal price. + *args, **kwargs: Additional arguments for internal price calculation. + + Returns: + A tuple representing the min, max internal price range for the part + """ + min_price: Money = None + max_price: Money = None + + for pb in self.part.internalpricebreaks.all(): + if pb.price is None: + continue + + cost = convert_currency(pb.price) + + if cost is None: + # Ignore if cost could not be converted for some reason + continue + + if min_price is None or cost < min_price: + min_price = cost + + if max_price is None or cost > max_price: + max_price = cost + + return PriceRangeTuple(min=min_price, max=max_price) + + def calculate_part_variant_price_range( + self, part, *args, **kwargs + ) -> PriceRangeTuple: + """Calculate the variant price range for a given part. + + Arguments: + part: The part instance for which to calculate the variant price. + *args, **kwargs: Additional arguments for variant price calculation. + + Returns: + A tuple representing the min, max variant price range for the part + + The "variant price range" is the price range for all variants of a template part + """ + variant_min: Money = None + variant_max: Money = None + + # If the part is not a template, return immediately + if not self.part.is_template: + return PriceRangeTuple(min=variant_min, max=variant_max) + + active_only = self.get_setting('PRICING_ACTIVE_VARIANTS', backup_value=True) + variants = part.get_descendants(include_self=False) + + variants = variants.prefetch_related('pricing') + + if active_only: + variants = variants.filter(active=True) + + for variant in variants: + v_min = convert_currency(variant.pricing.overall_min) + v_max = convert_currency(variant.pricing.overall_max) + + if v_min is not None: + if variant_min is None or v_min < variant_min: + variant_min = v_min + + if v_max is not None: + if variant_max is None or v_max > variant_max: + variant_max = v_max + + return PriceRangeTuple(min=variant_min, max=variant_max) + + def calculate_part_sale_price_range(self, part, *args, **kwargs) -> PriceRangeTuple: + """Calculate the sale price range for a given part. + + Arguments: + part: The part instance for which to calculate the sale price. + *args, **kwargs: Additional arguments for sale price calculation. + + Returns: + A tuple representing the min, max sale price range for the part + """ diff --git a/src/backend/InvenTree/plugin/mixins/__init__.py b/src/backend/InvenTree/plugin/mixins/__init__.py index 565e940bb983..fd8ea83aa188 100644 --- a/src/backend/InvenTree/plugin/mixins/__init__.py +++ b/src/backend/InvenTree/plugin/mixins/__init__.py @@ -11,6 +11,7 @@ from plugin.base.integration.MachineMixin import MachineDriverMixin from plugin.base.integration.NavigationMixin import NavigationMixin from plugin.base.integration.NotificationMixin import NotificationMixin +from plugin.base.integration.PricingMixin import PricingMixin from plugin.base.integration.ReportMixin import ReportMixin from plugin.base.integration.ScheduleMixin import ScheduleMixin from plugin.base.integration.SettingsMixin import SettingsMixin @@ -39,6 +40,7 @@ 'MailMixin', 'NavigationMixin', 'NotificationMixin', + 'PricingMixin', 'ReportMixin', 'ScheduleMixin', 'SettingsMixin', diff --git a/src/backend/InvenTree/plugin/plugin.py b/src/backend/InvenTree/plugin/plugin.py index 10e9a698e40f..94d1faa75c50 100644 --- a/src/backend/InvenTree/plugin/plugin.py +++ b/src/backend/InvenTree/plugin/plugin.py @@ -71,6 +71,7 @@ class PluginMixinEnum(StringEnum): MAIL = 'mail' NAVIGATION = 'navigation' NOTIFICATION = 'notification' + PRICING = 'pricing' REPORT = 'report' SCHEDULE = 'schedule' SETTINGS = 'settings' diff --git a/src/backend/InvenTree/plugin/registry.py b/src/backend/InvenTree/plugin/registry.py index 8c669b4d3b55..2ea1a5f41449 100644 --- a/src/backend/InvenTree/plugin/registry.py +++ b/src/backend/InvenTree/plugin/registry.py @@ -40,7 +40,7 @@ handle_error, log_registry_error, ) -from .plugin import InvenTreePlugin +from .plugin import InvenTreePlugin, PluginMixinEnum logger = structlog.get_logger('inventree') @@ -92,13 +92,14 @@ class PluginsRegistry: MANDATORY_PLUGINS = [ 'inventreebarcode', 'bom-exporter', - 'inventree-exporter', - 'inventree-ui-notification', - 'inventree-machines', - 'inventree-email-notification', 'inventreecurrencyexchange', 'inventreelabel', 'inventreelabelmachine', + 'inventree-email-notification', + 'inventree-exporter', + 'inventree-machines', + 'inventree-pricing', + 'inventree-ui-notification', 'parameter-exporter', ] @@ -1105,3 +1106,32 @@ def _load_source(modname, filename): loader.exec_module(module) return module + + +def get_plugin_options( + mixin: PluginMixinEnum, active: bool = True, allow_null: bool = True +) -> Optional[list]: + """Return a selection list of available plugins. + + Arguments: + mixin: The mixin type to filter plugins by + active: If True, only include active plugins + allow_null: If True, include a 'None' option at the start of the list + + Returns: + A list of tuples in the form (plugin_slug, plugin_name) for use in a ChoiceField + """ + try: + plugs = registry.with_mixin(mixin, active=active) + except Exception: + plugs = [] + + choices = [] + + if allow_null: + choices.append(('', _('No plugin'))) + + for plug in plugs: + choices.append((plug.slug, plug.human_name)) + + return choices diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/CurrencyManagementPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/CurrencyManagementPanel.tsx index a0972b445c10..3350bb4a81d9 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/CurrencyManagementPanel.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/CurrencyManagementPanel.tsx @@ -106,6 +106,7 @@ export default function CurrencyManagementPanel() {
@@ -157,10 +160,6 @@ export default function SystemSettings() { 'PRICING_ACTIVE_VARIANTS' ]} /> -
- ) },