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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user