Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Adds parameter support for multiple new model types in [#10699](https://github.com/inventree/InvenTree/pull/10699)
- Allows report generator to produce PDF input controls in [#10969](https://github.com/inventree/InvenTree/pull/10969)
- UI overhaul of parameter management in [#10699](https://github.com/inventree/InvenTree/pull/10699)
- Adds a API to gather live price information for a Part in [#10983](https://github.com/inventree/InvenTree/pull/10983)

### Changed

Expand Down
7 changes: 6 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"""InvenTree API version information."""

# InvenTree API version
INVENTREE_API_VERSION = 452
INVENTREE_API_VERSION = 453
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""

INVENTREE_API_TEXT = """

v453 -> 2026-02-12 : https://github.com/inventree/InvenTree/pull/10983
- Adds an API to gather dynamic price information for a Part

v452 -> 2026-02-10 : https://github.com/inventree/InvenTree/pull/11276
- Adds "install_into_detail" field to the BuildItem API endpoint

Expand All @@ -24,6 +27,7 @@

v447 -> 2026-02-02 : https://github.com/inventree/InvenTree/pull/11242
- Adds "sub_part_active" filter to BomItem API endpoint
>>>>>>> master

v446 -> 2026-02-01 : https://github.com/inventree/InvenTree/pull/11232
- Allow ordering of test results by started_datetime and finished_datetime fields
Expand Down Expand Up @@ -82,6 +86,7 @@
- Remove duplicate "address" field on the Company API endpoint
- Make "primary_address" field optional on the Company API endpoint
- Remove "address_count" field from the Company API endpoint
>>>>>>> 612e54b415d09686a450f6025b6787da4d91552e

v430 -> 2025-12-04 : https://github.com/inventree/InvenTree/pull/10699
- Removed the "PartParameter" and "PartParameterTemplate" API endpoints
Expand Down
36 changes: 36 additions & 0 deletions src/backend/InvenTree/part/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,37 @@ 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."""
try:
quantity = int(request.query_params.get('quantity', 1))
if quantity < 1:
raise serializers.ValidationError({
'quantity': _('Quantity must be a positive integer')
})
except ValueError:
raise serializers.ValidationError({
'quantity': _('Quantity must be an integer')
})

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

Expand Down Expand Up @@ -1703,6 +1734,11 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI):
meta_path(Part),
# 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'),
]),
Expand Down
37 changes: 29 additions & 8 deletions src/backend/InvenTree/part/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2176,10 +2176,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

Expand Down Expand Up @@ -2272,7 +2274,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.

Expand All @@ -2285,30 +2295,41 @@ 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 = (
self.get_bom_price_range(quantity, internal=internal) if bom else None
)

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(
Expand Down
12 changes: 12 additions & 0 deletions src/backend/InvenTree/part/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1529,6 +1529,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."""

Expand Down
Loading