2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-12 01:55:39 +00:00

[PUI] pricing tab (#6985)

* Add recharts package

- Brings us in-line with mantine v7

* Add skeleton pricing page

* Fetch pricing data

* Rough implementation of variant pricing chart

- Needs better labels for tooltip and axis

* Cleanup

* More cleanup

* Improve rendering

* Add pricing overview

* Add pie chart for BOM pricing

- Needs extra work!

* Split components into separate files

* Backend: allow ordering parts by pricing

* Bump API version

* Update VariantPricingPanel:

- Table drives data selection now

* Refactor BomPricingPanel

- Table drives the data

* Allow BomItemList to be sorted by pricing too

* Sort bom table

* Make record index available to render function

* Refactor BomPricingPanel

- Better rendering of pie chart

* Update pricing overview panel

* Further updates

- Expose "pricing_updated" column to API endpoints
- Allow ordering by "pricing_updated" column

* Update API endpoint for PurchaseOrderLineItem

* Implement PurchaseOrderHistory panel

* Cleanup PurchaseHistoryPanel

* Enhance API for SupplierPriceBreak

* Implement SupplierPricingPanel

* Fix for getDetailUrl

- Take base URL into account also!

* Further fixes for getDetailUrl

* Fix number form field

* Implement SupplierPriceBreakTable

* Tweaks for StockItemTable

* Ensure frontend is translated when compiling static files

* Fixes for BomPricingPanel

* Simplify price rendering for bom table

* Update BomItem serializer

- Add pricing_min_total
- Add pricing_max_total
- Fix existing 1+N query issue

* Use values provided by API

* Fix BomItem serializer lookup

* Refactor pricing charts

* Fix for VariantPricingPanel

* Remove unused imports

* Implement SalePriceBreak table

- Refactor the InternalPriceBreak table to be generic

* Allow price breaks to be ordered by 'price'

* Display alert for no available data

* Update backend API filters

* Allow ordering by customer

* Implement SaleHistoryPanel

* Allow user to select pie or bar chart for BOM pricing detail

* Remove extra padding
This commit is contained in:
Oliver
2024-04-16 01:05:56 +10:00
committed by GitHub
parent cfa06cd01e
commit d3a2eced97
30 changed files with 1894 additions and 69 deletions

View File

@ -1,11 +1,17 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 186
INVENTREE_API_VERSION = 187
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v187 - 2024-03-10 : https://github.com/inventree/InvenTree/pull/6985
- Allow Part list endpoint to be sorted by pricing_min and pricing_max values
- Allow BomItem list endpoint to be sorted by pricing_min and pricing_max values
- Allow InternalPrice and SalePrice endpoints to be sorted by quantity
- Adds total pricing values to BomItem serializer
v186 - 2024-03-26 : https://github.com/inventree/InvenTree/pull/6855
- Adds license information to the API

View File

@ -468,9 +468,13 @@ class SupplierPriceBreakList(ListCreateAPI):
return self.serializer_class(*args, **kwargs)
filter_backends = ORDER_FILTER
filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_fields = ['quantity']
ordering_fields = ['quantity', 'supplier', 'SKU', 'price']
search_fields = ['part__SKU', 'part__supplier__name']
ordering_field_aliases = {'supplier': 'part__supplier__name', 'SKU': 'part__SKU'}
ordering = 'quantity'

View File

@ -154,11 +154,11 @@ class LineItemFilter(rest_filters.FilterSet):
# Filter by order status
order_status = rest_filters.NumberFilter(
label='order_status', field_name='order__status'
label=_('Order Status'), field_name='order__status'
)
has_pricing = rest_filters.BooleanFilter(
label='Has Pricing', method='filter_has_pricing'
label=_('Has Pricing'), method='filter_has_pricing'
)
def filter_has_pricing(self, queryset, name, value):
@ -425,9 +425,38 @@ class PurchaseOrderLineItemFilter(LineItemFilter):
price_field = 'purchase_price'
model = models.PurchaseOrderLineItem
fields = ['order', 'part']
fields = []
pending = rest_filters.BooleanFilter(label='pending', method='filter_pending')
order = rest_filters.ModelChoiceFilter(
queryset=models.PurchaseOrder.objects.all(),
field_name='order',
label=_('Order'),
)
order_complete = rest_filters.BooleanFilter(
label=_('Order Complete'), method='filter_order_complete'
)
def filter_order_complete(self, queryset, name, value):
"""Filter by whether the order is 'complete' or not."""
if str2bool(value):
return queryset.filter(order__status=PurchaseOrderStatus.COMPLETE.value)
return queryset.exclude(order__status=PurchaseOrderStatus.COMPLETE.value)
part = rest_filters.ModelChoiceFilter(
queryset=SupplierPart.objects.all(), field_name='part', label=_('Supplier Part')
)
base_part = rest_filters.ModelChoiceFilter(
queryset=Part.objects.filter(purchaseable=True),
field_name='part__part',
label=_('Internal Part'),
)
pending = rest_filters.BooleanFilter(
method='filter_pending', label=_('Order Pending')
)
def filter_pending(self, queryset, name, value):
"""Filter by "pending" status (order status = pending)."""
@ -435,7 +464,9 @@ class PurchaseOrderLineItemFilter(LineItemFilter):
return queryset.filter(order__status__in=PurchaseOrderStatusGroups.OPEN)
return queryset.exclude(order__status__in=PurchaseOrderStatusGroups.OPEN)
received = rest_filters.BooleanFilter(label='received', method='filter_received')
received = rest_filters.BooleanFilter(
label=_('Items Received'), method='filter_received'
)
def filter_received(self, queryset, name, value):
"""Filter by lines which are "received" (or "not" received).
@ -542,25 +573,6 @@ class PurchaseOrderLineItemList(
serializer.data, status=status.HTTP_201_CREATED, headers=headers
)
def filter_queryset(self, queryset):
"""Additional filtering options."""
params = self.request.query_params
queryset = super().filter_queryset(queryset)
base_part = params.get('base_part', None)
if base_part:
try:
base_part = Part.objects.get(pk=base_part)
queryset = queryset.filter(part__part=base_part)
except (ValueError, Part.DoesNotExist):
pass
return queryset
def download_queryset(self, queryset, export_format):
"""Download the requested queryset as a file."""
dataset = PurchaseOrderLineItemResource().export(queryset=queryset)
@ -577,6 +589,8 @@ class PurchaseOrderLineItemList(
'MPN': 'part__manufacturer_part__MPN',
'SKU': 'part__SKU',
'part_name': 'part__part__name',
'order': 'order__reference',
'complete_date': 'order__complete_date',
}
ordering_fields = [
@ -589,6 +603,8 @@ class PurchaseOrderLineItemList(
'SKU',
'total_price',
'target_date',
'order',
'complete_date',
]
search_fields = [
@ -791,7 +807,15 @@ class SalesOrderLineItemFilter(LineItemFilter):
price_field = 'sale_price'
model = models.SalesOrderLineItem
fields = ['order', 'part']
fields = []
order = rest_filters.ModelChoiceFilter(
queryset=models.SalesOrder.objects.all(), field_name='order', label=_('Order')
)
part = rest_filters.ModelChoiceFilter(
queryset=Part.objects.all(), field_name='part', label=_('Part')
)
completed = rest_filters.BooleanFilter(label='completed', method='filter_completed')
@ -806,6 +830,17 @@ class SalesOrderLineItemFilter(LineItemFilter):
return queryset.filter(q)
return queryset.exclude(q)
order_complete = rest_filters.BooleanFilter(
label=_('Order Complete'), method='filter_order_complete'
)
def filter_order_complete(self, queryset, name, value):
"""Filter by whether the order is 'complete' or not."""
if str2bool(value):
return queryset.filter(order__status__in=SalesOrderStatusGroups.COMPLETE)
return queryset.exclude(order__status__in=SalesOrderStatusGroups.COMPLETE)
class SalesOrderLineItemMixin:
"""Mixin class for SalesOrderLineItem endpoints."""
@ -862,9 +897,24 @@ class SalesOrderLineItemList(SalesOrderLineItemMixin, APIDownloadMixin, ListCrea
return DownloadFile(filedata, filename)
filter_backends = SEARCH_ORDER_FILTER
filter_backends = SEARCH_ORDER_FILTER_ALIAS
ordering_fields = ['part__name', 'quantity', 'reference', 'target_date']
ordering_fields = [
'customer',
'order',
'part',
'part__name',
'quantity',
'reference',
'sale_price',
'target_date',
]
ordering_field_aliases = {
'customer': 'order__customer__name',
'part': 'part__name',
'order': 'order__reference',
}
search_fields = ['part__name', 'quantity', 'reference']

View File

@ -38,7 +38,6 @@ from InvenTree.helpers import (
is_ajax,
isNull,
str2bool,
str2int,
)
from InvenTree.mixins import (
CreateAPI,
@ -386,9 +385,10 @@ class PartSalePriceList(ListCreateAPI):
queryset = PartSellPriceBreak.objects.all()
serializer_class = part_serializers.PartSalePriceSerializer
filter_backends = [DjangoFilterBackend]
filter_backends = SEARCH_ORDER_FILTER
filterset_fields = ['part']
ordering_fields = ['quantity', 'price']
ordering = 'quantity'
class PartInternalPriceDetail(RetrieveUpdateDestroyAPI):
@ -405,9 +405,10 @@ class PartInternalPriceList(ListCreateAPI):
serializer_class = part_serializers.PartInternalPriceSerializer
permission_required = 'roles.sales_order.show'
filter_backends = [DjangoFilterBackend]
filter_backends = SEARCH_ORDER_FILTER
filterset_fields = ['part']
ordering_fields = ['quantity', 'price']
ordering = 'quantity'
class PartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
@ -1407,8 +1408,17 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI):
'category',
'last_stocktake',
'units',
'pricing_min',
'pricing_max',
'pricing_updated',
]
ordering_field_aliases = {
'pricing_min': 'pricing_data__overall_min',
'pricing_max': 'pricing_data__overall_max',
'pricing_updated': 'pricing_data__updated',
}
# Default ordering
ordering = 'name'
@ -1939,9 +1949,19 @@ class BomList(BomMixin, ListCreateDestroyAPIView):
'inherited',
'optional',
'consumable',
'pricing_min',
'pricing_max',
'pricing_min_total',
'pricing_max_total',
'pricing_updated',
]
ordering_field_aliases = {'sub_part': 'sub_part__name'}
ordering_field_aliases = {
'sub_part': 'sub_part__name',
'pricing_min': 'sub_part__pricing_data__overall_min',
'pricing_max': 'sub_part__pricing_data__overall_max',
'pricing_updated': 'sub_part__pricing_data__updated',
}
class BomDetail(BomMixin, RetrieveUpdateDestroyAPI):

View File

@ -616,6 +616,7 @@ class PartSerializer(
'virtual',
'pricing_min',
'pricing_max',
'pricing_updated',
'responsible',
# Annotated fields
'allocated_to_build_orders',
@ -678,6 +679,7 @@ class PartSerializer(
if not pricing:
self.fields.pop('pricing_min')
self.fields.pop('pricing_max')
self.fields.pop('pricing_updated')
def get_api_url(self):
"""Return the API url associated with this serializer."""
@ -843,6 +845,9 @@ class PartSerializer(
pricing_max = InvenTree.serializers.InvenTreeMoneySerializer(
source='pricing_data.overall_max', allow_null=True, read_only=True
)
pricing_updated = serializers.DateTimeField(
source='pricing_data.updated', allow_null=True, read_only=True
)
parameters = PartParameterSerializer(many=True, read_only=True)
@ -1413,6 +1418,9 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'part_detail',
'pricing_min',
'pricing_max',
'pricing_min_total',
'pricing_max_total',
'pricing_updated',
'quantity',
'reference',
'sub_part',
@ -1451,6 +1459,9 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
if not pricing:
self.fields.pop('pricing_min')
self.fields.pop('pricing_max')
self.fields.pop('pricing_min_total')
self.fields.pop('pricing_max_total')
self.fields.pop('pricing_updated')
quantity = InvenTree.serializers.InvenTreeDecimalField(required=True)
@ -1481,10 +1492,22 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
# Cached pricing fields
pricing_min = InvenTree.serializers.InvenTreeMoneySerializer(
source='sub_part.pricing.overall_min', allow_null=True, read_only=True
source='sub_part.pricing_data.overall_min', allow_null=True, read_only=True
)
pricing_max = InvenTree.serializers.InvenTreeMoneySerializer(
source='sub_part.pricing.overall_max', allow_null=True, read_only=True
source='sub_part.pricing_data.overall_max', allow_null=True, read_only=True
)
pricing_min_total = InvenTree.serializers.InvenTreeMoneySerializer(
allow_null=True, read_only=True
)
pricing_max_total = InvenTree.serializers.InvenTreeMoneySerializer(
allow_null=True, read_only=True
)
pricing_updated = serializers.DateTimeField(
source='sub_part.pricing_data.updated', allow_null=True, read_only=True
)
# Annotated fields for available stock
@ -1504,6 +1527,7 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
queryset = queryset.prefetch_related('sub_part')
queryset = queryset.prefetch_related('sub_part__category')
queryset = queryset.prefetch_related('sub_part__pricing_data')
queryset = queryset.prefetch_related(
'sub_part__stock_items',
@ -1531,6 +1555,18 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
available_stock = total_stock - build_order_allocations - sales_order_allocations
"""
# Annotate with the 'total pricing' information based on unit pricing and quantity
queryset = queryset.annotate(
pricing_min_total=ExpressionWrapper(
F('quantity') * F('sub_part__pricing_data__overall_min'),
output_field=models.DecimalField(),
),
pricing_max_total=ExpressionWrapper(
F('quantity') * F('sub_part__pricing_data__overall_max'),
output_field=models.DecimalField(),
),
)
ref = 'sub_part__'
# Annotate with the total "on order" amount for the sub-part