Skip to content

Commit

Permalink
[PUI] pricing tab (#6985)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
SchrodingersGat committed Apr 15, 2024
1 parent cfa06cd commit d3a2ece
Show file tree
Hide file tree
Showing 30 changed files with 1,894 additions and 69 deletions.
8 changes: 7 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
@@ -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
Expand Down
8 changes: 6 additions & 2 deletions src/backend/InvenTree/company/api.py
Expand Up @@ -468,9 +468,13 @@ def get_serializer(self, *args, **kwargs):

return self.serializer_class(*args, **kwargs)

filter_backends = ORDER_FILTER
filter_backends = SEARCH_ORDER_FILTER_ALIAS

ordering_fields = ['quantity', 'supplier', 'SKU', 'price']

search_fields = ['part__SKU', 'part__supplier__name']

ordering_fields = ['quantity']
ordering_field_aliases = {'supplier': 'part__supplier__name', 'SKU': 'part__SKU'}

ordering = 'quantity'

Expand Down
104 changes: 77 additions & 27 deletions src/backend/InvenTree/order/api.py
Expand Up @@ -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):
Expand Down Expand Up @@ -425,17 +425,48 @@ class Meta:

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)."""
if str2bool(value):
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).
Expand Down Expand Up @@ -542,25 +573,6 @@ def create(self, request, *args, **kwargs):
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)
Expand All @@ -577,6 +589,8 @@ def download_queryset(self, queryset, export_format):
'MPN': 'part__manufacturer_part__MPN',
'SKU': 'part__SKU',
'part_name': 'part__part__name',
'order': 'order__reference',
'complete_date': 'order__complete_date',
}

ordering_fields = [
Expand All @@ -589,6 +603,8 @@ def download_queryset(self, queryset, export_format):
'SKU',
'total_price',
'target_date',
'order',
'complete_date',
]

search_fields = [
Expand Down Expand Up @@ -791,7 +807,15 @@ class Meta:

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')

Expand All @@ -806,6 +830,17 @@ def filter_completed(self, queryset, name, value):
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."""
Expand Down Expand Up @@ -862,9 +897,24 @@ def download_queryset(self, queryset, export_format):

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']

Expand Down
32 changes: 26 additions & 6 deletions src/backend/InvenTree/part/api.py
Expand Up @@ -38,7 +38,6 @@
is_ajax,
isNull,
str2bool,
str2int,
)
from InvenTree.mixins import (
CreateAPI,
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -1407,8 +1408,17 @@ def filter_parametric_data(self, queryset):
'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'

Expand Down Expand Up @@ -1939,9 +1949,19 @@ def list(self, request, *args, **kwargs):
'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):
Expand Down
40 changes: 38 additions & 2 deletions src/backend/InvenTree/part/serializers.py
Expand Up @@ -616,6 +616,7 @@ class Meta:
'virtual',
'pricing_min',
'pricing_max',
'pricing_updated',
'responsible',
# Annotated fields
'allocated_to_build_orders',
Expand Down Expand Up @@ -678,6 +679,7 @@ def __init__(self, *args, **kwargs):
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."""
Expand Down Expand Up @@ -843,6 +845,9 @@ def get_starred(self, part) -> bool:
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)

Expand Down Expand Up @@ -1413,6 +1418,9 @@ class Meta:
'part_detail',
'pricing_min',
'pricing_max',
'pricing_min_total',
'pricing_max_total',
'pricing_updated',
'quantity',
'reference',
'sub_part',
Expand Down Expand Up @@ -1451,6 +1459,9 @@ def __init__(self, *args, **kwargs):
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)

Expand Down Expand Up @@ -1481,10 +1492,22 @@ def validate_quantity(self, quantity):

# 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
Expand All @@ -1504,6 +1527,7 @@ def setup_eager_loading(queryset):

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',
Expand Down Expand Up @@ -1531,6 +1555,18 @@ def annotate_queryset(queryset):
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
Expand Down
1 change: 1 addition & 0 deletions src/frontend/package.json
Expand Up @@ -49,6 +49,7 @@
"react-router-dom": "^6.22.1",
"react-select": "^5.8.0",
"react-simplemde-editor": "^5.2.0",
"recharts": "^2.12.4",
"styled-components": "^5.3.6",
"zustand": "^4.5.1"
},
Expand Down
12 changes: 12 additions & 0 deletions src/frontend/src/components/charts/colors.tsx
@@ -0,0 +1,12 @@
export const CHART_COLORS: string[] = [
'#ffa8a8',
'#8ce99a',
'#74c0fc',
'#ffe066',
'#63e6be',
'#ffc078',
'#d8f5a2',
'#66d9e8',
'#e599f7',
'#dee2e6'
];

0 comments on commit d3a2ece

Please sign in to comment.