2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-12-16 17:28:11 +00:00

[UI] Suggested pricing (#10867)

* Refactor NumberField into separate component

* Add helper func to ensure a number is a number

* Use placeholder value for suggested sale price

* Fix for auto-fill

* Tweak price calculation

* Add UI testing for sales order price breaks

* Fix aria label name

* Annotate price breaks to supplier part

* Fetch price break data

* Support price breaks for purchase order pricing

* Fix required to prevent circular imports

* Add playwright tests for purchase order price breaks

* Bump API version

* Re-add output options for SupplierPriceBreakList

* Revert change

* Simplify unit test
This commit is contained in:
Oliver
2025-11-20 22:51:27 +11:00
committed by GitHub
parent 835c7784f9
commit a76ec0a7b8
16 changed files with 465 additions and 201 deletions

View File

@@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 425
INVENTREE_API_VERSION = 426
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v426 -> 2025-11-19 : https://github.com/inventree/InvenTree/pull/10867
- Adds optional "price_breaks" filter to the SupplierPart API endpoint
v425 -> 2025-11-11 : https://github.com/inventree/InvenTree/pull/10802
- Adds "on_order" filter to the BuildLine API endpoint
- Allow BuildLine list to be ordered by "on_order" and "in_production" fields

View File

@@ -460,6 +460,20 @@ class SupplierPriceBreakFilter(FilterSet):
)
class SupplierPriceBreakMixin:
"""Mixin class for SupplierPriceBreak API endpoints."""
queryset = SupplierPriceBreak.objects.all()
serializer_class = SupplierPriceBreakSerializer
def get_queryset(self):
"""Return annotated queryset for the SupplierPriceBreak list endpoint."""
queryset = super().get_queryset()
queryset = SupplierPriceBreakSerializer.annotate_queryset(queryset)
return queryset
class SupplierPriceBreakOutputOptions(OutputConfiguration):
"""Available output options for the SupplierPriceBreak endpoints."""
@@ -477,27 +491,19 @@ class SupplierPriceBreakOutputOptions(OutputConfiguration):
]
class SupplierPriceBreakList(SerializerContextMixin, OutputOptionsMixin, ListCreateAPI):
class SupplierPriceBreakList(
SupplierPriceBreakMixin, SerializerContextMixin, OutputOptionsMixin, ListCreateAPI
):
"""API endpoint for list view of SupplierPriceBreak object.
- GET: Retrieve list of SupplierPriceBreak objects
- POST: Create a new SupplierPriceBreak object
"""
queryset = SupplierPriceBreak.objects.all()
serializer_class = SupplierPriceBreakSerializer
filterset_class = SupplierPriceBreakFilter
output_options = SupplierPriceBreakOutputOptions
def get_queryset(self):
"""Return annotated queryset for the SupplierPriceBreak list endpoint."""
queryset = super().get_queryset()
queryset = SupplierPriceBreakSerializer.annotate_queryset(queryset)
return queryset
filterset_class = SupplierPriceBreakFilter
filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_fields = ['quantity', 'supplier', 'SKU', 'price']
search_fields = ['part__SKU', 'part__supplier__name']
@@ -507,19 +513,9 @@ class SupplierPriceBreakList(SerializerContextMixin, OutputOptionsMixin, ListCre
ordering = 'quantity'
class SupplierPriceBreakDetail(RetrieveUpdateDestroyAPI):
class SupplierPriceBreakDetail(SupplierPriceBreakMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for SupplierPriceBreak object."""
queryset = SupplierPriceBreak.objects.all()
serializer_class = SupplierPriceBreakSerializer
def get_queryset(self):
"""Return annotated queryset for the SupplierPriceBreak list endpoint."""
queryset = super().get_queryset()
queryset = SupplierPriceBreakSerializer.annotate_queryset(queryset)
return queryset
manufacturer_part_api_urls = [
path(

View File

@@ -329,6 +329,39 @@ class ManufacturerPartParameterSerializer(
)
class SupplierPriceBreakBriefSerializer(
FilterableSerializerMixin, InvenTreeModelSerializer
):
"""Brief serializer for SupplierPriceBreak object.
Used to provide a list of price breaks against the SupplierPart object.
"""
no_filters = True
class Meta:
"""Metaclass options."""
model = SupplierPriceBreak
fields = [
'pk',
'part',
'quantity',
'price',
'price_currency',
'supplier',
'updated',
]
quantity = InvenTreeDecimalField()
price = InvenTreeMoneySerializer(allow_null=True, required=True, label=_('Price'))
price_currency = InvenTreeCurrencySerializer()
supplier = serializers.PrimaryKeyRelatedField(
source='part.supplier', many=False, read_only=True
)
@register_importer()
class SupplierPartSerializer(
FilterableSerializerMixin,
@@ -373,14 +406,15 @@ class SupplierPartSerializer(
'pack_quantity',
'pack_quantity_native',
'part',
'part_detail',
'pretty_name',
'SKU',
'supplier',
'supplier_detail',
'updated',
'notes',
'part_detail',
'tags',
'price_breaks',
]
read_only_fields = [
'availability_updated',
@@ -441,6 +475,18 @@ class SupplierPartSerializer(
pack_quantity_native = serializers.FloatField(read_only=True)
price_breaks = enable_filter(
SupplierPriceBreakBriefSerializer(
source='pricebreaks',
many=True,
read_only=True,
allow_null=True,
label=_('Price Breaks'),
),
False,
filter_name='price_breaks',
)
part_detail = part_serializers.PartBriefSerializer(
label=_('Part'), source='part', many=False, read_only=True, allow_null=True
)
@@ -489,6 +535,8 @@ class SupplierPartSerializer(
Fields:
in_stock: Current stock quantity for each SupplierPart
"""
queryset = queryset.prefetch_related('part', 'pricebreaks')
queryset = queryset.annotate(in_stock=part.filters.annotate_total_stock())
queryset = queryset.annotate(
@@ -532,24 +580,24 @@ class SupplierPartSerializer(
@register_importer()
class SupplierPriceBreakSerializer(
FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer
SupplierPriceBreakBriefSerializer,
DataImportExportSerializerMixin,
InvenTreeModelSerializer,
):
"""Serializer for SupplierPriceBreak object."""
"""Serializer for SupplierPriceBreak object.
Note that this inherits from the SupplierPriceBreakBriefSerializer,
and does so to prevent circular serializer import issues.
"""
class Meta:
"""Metaclass options."""
model = SupplierPriceBreak
fields = [
'pk',
'part',
'part_detail',
'quantity',
'price',
'price_currency',
'supplier',
*SupplierPriceBreakBriefSerializer.Meta.fields,
'supplier_detail',
'updated',
'part_detail',
]
@staticmethod
@@ -559,25 +607,15 @@ class SupplierPriceBreakSerializer(
return queryset
quantity = InvenTreeDecimalField()
price = InvenTreeMoneySerializer(allow_null=True, required=True, label=_('Price'))
price_currency = InvenTreeCurrencySerializer()
supplier = serializers.PrimaryKeyRelatedField(
source='part.supplier', many=False, read_only=True
)
supplier_detail = enable_filter(
CompanyBriefSerializer(
source='part.supplier', many=False, read_only=True, allow_null=True
)
)
# Detail serializer for SupplierPart
part_detail = enable_filter(
SupplierPartSerializer(
source='part', brief=True, many=False, read_only=True, allow_null=True
)
),
False,
)

View File

@@ -730,41 +730,13 @@ class PartSerializer(
- Allows us to optionally pass extra fields based on the query.
"""
self.starred_parts = kwargs.pop('starred_parts', [])
# category_detail = kwargs.pop('category_detail', False)
# location_detail = kwargs.pop('location_detail', False)
# parameters = kwargs.pop('parameters', False)
create = kwargs.pop('create', False)
# pricing = kwargs.pop('pricing', True)
# path_detail = kwargs.pop('path_detail', False)
# price_breaks = kwargs.pop('price_breaks', False)
super().__init__(*args, **kwargs)
if isGeneratingSchema():
return
"""
if not category_detail:
self.fields.pop('category_detail', None)
if not location_detail:
self.fields.pop('default_location_detail', None)
if not parameters:
self.fields.pop('parameters', None)
if not path_detail:
self.fields.pop('category_path', None)
if not price_breaks:
self.fields.pop('price_breaks', None)
if not pricing:
self.fields.pop('pricing_min', None)
self.fields.pop('pricing_max', None)
self.fields.pop('pricing_updated', None)
"""
if not create:
# These fields are only used for the LIST API endpoint
for f in self.skip_create_fields():