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