diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 324e08988d74..781cac209b1e 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 430 +INVENTREE_API_VERSION = 431 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v431 -> 2025-12-09 : https://github.com/inventree/InvenTree/pull/10983 + - Adds an API to gather dynamic price information for a Part + v430 -> 2025-12-04 : https://github.com/inventree/InvenTree/pull/10699 - Removed the "PartParameter" and "PartParameterTemplate" API endpoints - Removed the "ManufacturerPartParameter" API endpoint diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 57fcc7cb85cb..52a3eb8a39db 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -585,6 +585,27 @@ def _get_serializer(self, *args, **kwargs): return self.serializer_class(**kwargs) +class PartLivePricingDetail(RetrieveAPI): + """API endpoint for calculating live part pricing data - this might be compute intensive.""" + + queryset = Part.objects.all() + serializer_class = part_serializers.PartLivePricingSerializer + + def retrieve(self, request, *args, **kwargs): + """Fetch live pricing data for this part.""" + quantity = 1 # TODO: retrieve from request parameters + part: Part = self.get_object() + pricing = part.get_price_range( + quantity, buy=True, bom=True, internal=True, purchase=True, info=True + ) + serializer = self.get_serializer({ + 'price_min': pricing[0], + 'price_max': pricing[1], + 'source': pricing[2], + }) + return Response(serializer.data) + + class PartSerialNumberDetail(RetrieveAPI): """API endpoint for returning extra serial number information about a particular part.""" @@ -1652,6 +1673,11 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI): ), # Part pricing path('pricing/', PartPricingDetail.as_view(), name='api-part-pricing'), + path( + 'pricing-live/', + PartLivePricingDetail.as_view(), + name='api-part-pricing-live', + ), # Part detail endpoint path('', PartDetail.as_view(), name='api-part-detail'), ]), diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 2f2b74961f06..81e74440b659 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -10,7 +10,7 @@ import re from datetime import timedelta from decimal import Decimal, InvalidOperation -from typing import cast +from typing import Literal, cast from django.conf import settings from django.contrib.auth.models import User @@ -2195,10 +2195,12 @@ def get_price_info(self, quantity=1, buy=True, bom=True, internal=False): bom: Include BOM pricing (default = True) internal: Include internal pricing (default = False) """ - price_range = self.get_price_range(quantity, buy, bom, internal) + price_range = self.get_price_range(quantity, buy, bom, internal, info=False) if price_range is None: return None + if len(price_range) != 2: + return None min_price, max_price = price_range @@ -2291,7 +2293,15 @@ def get_bom_price_range(self, quantity=1, internal=False, purchase=False): return (min_price, max_price) def get_price_range( - self, quantity=1, buy=True, bom=True, internal=False, purchase=False + self, quantity=1, buy=True, bom=True, internal=False, purchase=False, info=False + ) -> ( + tuple[Decimal | None, Decimal | None] + | tuple[ + Decimal | None, + Decimal | None, + Literal['internal', 'purchase', 'bom', 'buy', 'buy/bom'], + ] + | None ): """Return the price range for this part. @@ -2304,16 +2314,24 @@ def get_price_range( Returns: Minimum of the supplier, BOM, internal or purchase price. If no pricing available, returns None """ + + def return_info(r_min, r_max, source: str): + if not info: + return r_min, r_max + return r_min, r_max, source + # only get internal price if set and should be used if internal and self.has_internal_price_breaks: internal_price = self.get_internal_price(quantity) - return internal_price, internal_price + return return_info(internal_price, internal_price, 'internal') + + # TODO add sales pricing option? # only get purchase price if set and should be used if purchase: purchase_price = self.get_purchase_price(quantity) if purchase_price: - return purchase_price + return return_info(*purchase_price, 'purchase') # type: ignore[too-many-positional-arguments] buy_price_range = self.get_supplier_price_range(quantity) if buy else None bom_price_range = ( @@ -2321,13 +2339,16 @@ def get_price_range( ) if buy_price_range is None: - return bom_price_range + if bom_price_range is None: + return None + return return_info(*bom_price_range, 'bom') # type: ignore[too-many-positional-arguments] elif bom_price_range is None: - return buy_price_range - return ( + return return_info(*buy_price_range, 'buy') # type: ignore[too-many-positional-arguments] + return return_info( min(buy_price_range[0], bom_price_range[0]), max(buy_price_range[1], bom_price_range[1]), + 'buy/bom', ) base_cost = models.DecimalField( diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 8a9d1e1acb67..e6fcf73255a2 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -1441,6 +1441,18 @@ def save(self): pricing.update_pricing() +class PartLivePricingSerializer(serializers.Serializer): + """Serializer for Part live pricing information.""" + + price_min = InvenTree.serializers.InvenTreeMoneySerializer( + allow_null=True, read_only=True + ) + price_max = InvenTree.serializers.InvenTreeMoneySerializer( + allow_null=True, read_only=True + ) + source = serializers.CharField(allow_null=True, read_only=True) + + class PartSerialNumberSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Serializer for Part serial number information."""