2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-18 13:05:42 +00:00

Part pricing cache (#3710)

* Create new model for storing Part pricing data

Currently this model does not "do" anything but will be used for caching pre-calculated pricing information

* Define function for accessing pricing information for a specific part

* Adds admin site support for new PartPricing model

* Specify role for PartPricing model

* Allow blank values for PartPricing model fields

* Add some TODO entries

* Update migration files to sync with latest master

* Expose API endpoint for viewing part pricing information

* Update migration file

* Improvements:

- Updated model with new fields
- Code for calculating BOM price
- Code for calculating internal price
- Code for calculating supplier price
- Updated unit testing

* Fix (and test) for API serializer

* Including min/max pricing data in part serializer

* Bump API version

* Add pricing overview information in part table

- Adds helper function for formatting currency data
- No longer pre-render "price strings" on the server

* Overhaul of BOM API

- Pricing data no longer calculated "on the fly"
- Remove expensive annotation operations
- Display cached price range information in BOM table

* Filter BOM items by "has pricing"

* Part API endpoint can be filtered by price range

* Updpated API version notes

* Improvements for price caching calculations

- Handle null price values
- Handle case where conversion rates are missing
- Allow manual update via API

* Button to manually refresh pricing

* Improve rendering of price-break table

* Update supplier part pricing table

* Updated js functions

* Adds background task to update assembly pricing whenever a part price cache is changed

* Updates for task offloading

* HTML tweaks

* Implement calculation of historical purchase cost

- take supplier part pack size into account
- improve unit tests

* Improvements for pricing tab rendering

* Refactor of pricing page

- Move javascript functions out into separate files
- Change price-break tables to use bar graphs
- Display part pricing history table and chart
- Remove server-side rendering for price history data
- Fix rendering of supplier pricing table
- Adds extra filtering options to the SupplierPriceBreak API endpoint

* Refactor BOM pricing chart / table

- Display as bar chart with min/max pricing
- Display simplified BOM table

* Update page anchors

* Improvements for BOM pricing table display

* Refactoring sales data tables

- Add extra data and filter options to sales order API endpoints
- Display sales order history table and chart

* Add extra fields to PartPricing model:

- sale_price_min
- sale_price_max
- sale_history_min
- sale_history_max

* Calculate and cache sale price data

* Update part pricing when PurchaseOrder is completed

* Update part pricing when sales order is completed

* Signals for updating part pricing cache

- Whenever an internal price break is created / edited / deleted
- Whenever a sale price break is created / edited / deleted

* Also trigger part pricing update when BomItem is created  / edited / deleted

* Update part pricing whenever a supplier price break is updated

* Remove has_complete_bom_pricing method

* Export min/max pricing data in BOM file

* Fix pricing data in BOM export

- Calculate total line cost
- Use more than two digits

* Add pricing information to part export

Also some improvements to part exporting

* Allow download of part category table

* Allow export of stock location data to file

* Improved exporting of StockItem data

* Add cached variant pricing data

- New fields in part pricing model
- Display variant pricing overview in "pricing" tab

* Remove outdated "PART_SHOW_PRICE_HISTORY" setting

* Adds scheduled background task to periodically update part pricing

* Internal prices can optionally override other pricing

* Update js file checks

* Update price breaks to use 6 decimal places

* Fix for InvenTreeMoneySerializer class

- Allow 6 decimal places through the API

* Update for supplier price break table

* javascript linting fix

* Further js fixes

* Unit test updates

* Improve rendering of currency in templates

- Do not artificially limit to 2 decimal places

* Unit test fixes

* Add pricing information to part "details" tab

* Tweak for money formatting

* Enable sort-by-price in BOM table

* More unit test tweaks

* Update BOM exporting

* Fixes for background worker process

- To determine if worker is running, look for *any* successful task, not just heartbeat
- Heartbeat rate increased to 5 minute intervals
- Small adjustments to django_q settings

Ref: https://github.com/inventree/InvenTree/issues/3921
(cherry picked from commit cb26003b92)

* Force background processing of heartbeat task when server is started

- Removes the ~5 minute window in which the server "thinks" that the worker is not actually running

* Adjust strategy for preventing recursion

- Rather than looking for duplicate parts, simply increment a counter
- Add a "scheduled_for_update" flag to prevent multiple updates being scheduled
- Consolidate migration files

* Adds helper function for rendering a range of prices

* Include variant cost in calculations

* Fixes for "has_pricing" API filters

* Ensure part pricing status flags are reset when the server restarts

* Bug fix for BOM API filter

* Include BOM quantity in BOM pricing chart

* Small tweaks to pricing tab

* Prevent caching when looking up settings in background worker

- Caching across mnultiple processes causes issues
- Need to move to something like redis to solve this
- Ref: https://github.com/inventree/InvenTree/issues/3921

* Fixes for /part/pricing/ detail API endpoint

* Update pricing tab

- Consistent naming

* Unit test fixes

* Prevent pricing updates when loading test fixtures

* Fix for Part.pricing

* Updates for "check_missing_pricing"

* Change to pie chart for BOM pricing

* Unit test fix

* Updates

- Sort BOM pie chart correctly
- Simplify PartPricing.is_valid
- Pass "limit" through to check_missing_pricing
- Improved logic for update scheduling

* Add option for changing how many decimals to use when displaying pricing data

* remove old unused setting

* Consolidate settings tabs for pricing and currencies

* Fix CI after changing settings page

* Fix rendering for "Supplier Pricing"

- Take unit pricing / pack size into account

* Extra filtering / ordering options for the SupplierPriceBreak API endpoint

* Fix for purchase price history graph

- Use unit pricing (take pack size into account)

* JS fixes
This commit is contained in:
Oliver
2022-11-14 15:58:22 +11:00
committed by GitHub
parent 8ceb1af3c3
commit 06266b48af
69 changed files with 3747 additions and 1629 deletions

View File

@ -1,6 +1,7 @@
"""Admin class definitions for the 'part' app"""
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
import import_export.widgets as widgets
from import_export.admin import ImportExportModelAdmin
@ -15,29 +16,57 @@ from stock.models import StockLocation
class PartResource(InvenTreeResource):
"""Class for managing Part data import/export."""
# ForeignKey fields
category = Field(attribute='category', widget=widgets.ForeignKeyWidget(models.PartCategory))
id = Field(attribute='pk', column_name=_('Part ID'), widget=widgets.IntegerWidget())
name = Field(attribute='name', column_name=_('Part Name'), widget=widgets.CharWidget())
description = Field(attribute='description', column_name=_('Part Description'), widget=widgets.CharWidget())
IPN = Field(attribute='IPN', column_name=_('IPN'), widget=widgets.CharWidget())
revision = Field(attribute='revision', column_name=_('Revision'), widget=widgets.CharWidget())
keywords = Field(attribute='keywords', column_name=_('Keywords'), widget=widgets.CharWidget())
link = Field(attribute='link', column_name=_('Link'), widget=widgets.CharWidget())
units = Field(attribute='units', column_name=_('Units'), widget=widgets.CharWidget())
notes = Field(attribute='notes', column_name=_('Notes'))
category = Field(attribute='category', column_name=_('Category ID'), widget=widgets.ForeignKeyWidget(models.PartCategory))
category_name = Field(attribute='category__name', column_name=_('Category Name'), readonly=True)
default_location = Field(attribute='default_location', column_name=_('Default Location ID'), widget=widgets.ForeignKeyWidget(StockLocation))
default_supplier = Field(attribute='default_supplier', column_name=_('Default Supplier ID'), widget=widgets.ForeignKeyWidget(SupplierPart))
variant_of = Field(attribute='variant_of', column_name=('Variant Of'), widget=widgets.ForeignKeyWidget(models.Part))
minimum_stock = Field(attribute='minimum_stock', column_name=_('Minimum Stock'))
default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation))
default_supplier = Field(attribute='default_supplier', widget=widgets.ForeignKeyWidget(SupplierPart))
category_name = Field(attribute='category__name', readonly=True)
variant_of = Field(attribute='variant_of', widget=widgets.ForeignKeyWidget(models.Part))
suppliers = Field(attribute='supplier_count', readonly=True)
# Part Attributes
active = Field(attribute='active', column_name=_('Active'), widget=widgets.BooleanWidget())
assembly = Field(attribute='assembly', column_name=_('Assembly'), widget=widgets.BooleanWidget())
component = Field(attribute='component', column_name=_('Component'), widget=widgets.BooleanWidget())
purchaseable = Field(attribute='purchaseable', column_name=_('Purchaseable'), widget=widgets.BooleanWidget())
salable = Field(attribute='salable', column_name=_('Salable'), widget=widgets.BooleanWidget())
is_template = Field(attribute='is_template', column_name=_('Template'), widget=widgets.BooleanWidget())
trackable = Field(attribute='trackable', column_name=_('Trackable'), widget=widgets.BooleanWidget())
virtual = Field(attribute='virtual', column_name=_('Virtual'), widget=widgets.BooleanWidget())
# Extra calculated meta-data (readonly)
in_stock = Field(attribute='total_stock', readonly=True, widget=widgets.IntegerWidget())
suppliers = Field(attribute='supplier_count', column_name=_('Suppliers'), readonly=True)
in_stock = Field(attribute='total_stock', column_name=_('In Stock'), readonly=True, widget=widgets.IntegerWidget())
on_order = Field(attribute='on_order', column_name=_('On Order'), readonly=True, widget=widgets.IntegerWidget())
used_in = Field(attribute='used_in_count', column_name=_('Used In'), readonly=True, widget=widgets.IntegerWidget())
allocated = Field(attribute='allocation_count', column_name=_('Allocated'), readonly=True, widget=widgets.IntegerWidget())
building = Field(attribute='quantity_being_built', column_name=_('Building'), readonly=True, widget=widgets.IntegerWidget())
min_cost = Field(attribute='pricing__overall_min', column_name=_('Minimum Cost'), readonly=True)
max_cost = Field(attribute='pricing__overall_max', column_name=_('Maximum Cost'), readonly=True)
on_order = Field(attribute='on_order', readonly=True, widget=widgets.IntegerWidget())
def dehydrate_min_cost(self, part):
"""Render minimum cost value for this Part"""
used_in = Field(attribute='used_in_count', readonly=True, widget=widgets.IntegerWidget())
min_cost = part.pricing.overall_min if part.pricing else None
allocated = Field(attribute='allocation_count', readonly=True, widget=widgets.IntegerWidget())
if min_cost is not None:
return float(min_cost.amount)
building = Field(attribute='quantity_being_built', readonly=True, widget=widgets.IntegerWidget())
def dehydrate_max_cost(self, part):
"""Render maximum cost value for this Part"""
max_cost = part.pricing.overall_max if part.pricing else None
if max_cost is not None:
return float(max_cost.amount)
class Meta:
"""Metaclass definition"""
@ -48,7 +77,9 @@ class PartResource(InvenTreeResource):
exclude = [
'bom_checksum', 'bom_checked_by', 'bom_checked_date',
'lft', 'rght', 'tree_id', 'level',
'image',
'metadata',
'barcode_data', 'barcode_hash',
]
def get_queryset(self):
@ -92,14 +123,30 @@ class PartAdmin(ImportExportModelAdmin):
]
class PartPricingAdmin(admin.ModelAdmin):
"""Admin class for PartPricing model"""
list_display = ('part', 'overall_min', 'overall_max')
autcomplete_fields = [
'part',
]
class PartCategoryResource(InvenTreeResource):
"""Class for managing PartCategory data import/export."""
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(models.PartCategory))
id = Field(attribute='pk', column_name=_('Category ID'))
name = Field(attribute='name', column_name=_('Category Name'))
description = Field(attribute='description', column_name=_('Description'))
parent = Field(attribute='parent', column_name=_('Parent ID'), widget=widgets.ForeignKeyWidget(models.PartCategory))
parent_name = Field(attribute='parent__name', column_name=_('Parent Name'), readonly=True)
default_location = Field(attribute='default_location', column_name=_('Default Location ID'), widget=widgets.ForeignKeyWidget(StockLocation))
default_keywords = Field(attribute='default_keywords', column_name=_('Keywords'))
pathstring = Field(attribute='pathstring', column_name=_('Category Path'))
parent_name = Field(attribute='parent__name', readonly=True)
default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation))
# Calculated fields
parts = Field(attribute='item_count', column_name=_('Parts'), widget=widgets.IntegerWidget(), readonly=True)
class Meta:
"""Metaclass definition"""
@ -112,6 +159,7 @@ class PartCategoryResource(InvenTreeResource):
# Exclude MPTT internal model fields
'lft', 'rght', 'tree_id', 'level',
'metadata',
'icon',
]
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
@ -160,33 +208,41 @@ class PartTestTemplateAdmin(admin.ModelAdmin):
class BomItemResource(InvenTreeResource):
"""Class for managing BomItem data import/export."""
level = Field(attribute='level', readonly=True)
level = Field(attribute='level', column_name=_('BOM Level'), readonly=True)
bom_id = Field(attribute='pk')
bom_id = Field(attribute='pk', column_name=_('BOM Item ID'))
# ID of the parent part
parent_part_id = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part))
parent_part_id = Field(attribute='part', column_name=_('Parent ID'), widget=widgets.ForeignKeyWidget(models.Part))
parent_part_ipn = Field(attribute='part__IPN', column_name=_('Parent IPN'), readonly=True)
parent_part_name = Field(attribute='part__name', column_name=_('Parent Name'), readonly=True)
part_id = Field(attribute='sub_part', column_name=_('Part ID'), widget=widgets.ForeignKeyWidget(models.Part))
part_ipn = Field(attribute='sub_part__IPN', column_name=_('Part IPN'), readonly=True)
part_name = Field(attribute='sub_part__name', column_name=_('Part Name'), readonly=True)
part_description = Field(attribute='sub_part__description', column_name=_('Description'), readonly=True)
quantity = Field(attribute='quantity', column_name=_('Quantity'))
reference = Field(attribute='reference', column_name=_('Reference'))
note = Field(attribute='note', column_name=_('Note'))
min_cost = Field(attribute='sub_part__pricing__overall_min', column_name=_('Minimum Price'), readonly=True)
max_cost = Field(attribute='sub_part__pricing__overall_max', column_name=_('Maximum Price'), readonly=True)
# IPN of the parent part
parent_part_ipn = Field(attribute='part__IPN', readonly=True)
sub_assembly = Field(attribute='sub_part__assembly', column_name=_('Assembly'), readonly=True)
# Name of the parent part
parent_part_name = Field(attribute='part__name', readonly=True)
def dehydrate_min_cost(self, item):
"""Render minimum cost value for the BOM line item"""
# ID of the sub-part
part_id = Field(attribute='sub_part', widget=widgets.ForeignKeyWidget(models.Part))
min_price = item.sub_part.pricing.overall_min if item.sub_part.pricing else None
# IPN of the sub-part
part_ipn = Field(attribute='sub_part__IPN', readonly=True)
if min_price is not None:
return float(min_price.amount) * float(item.quantity)
# Name of the sub-part
part_name = Field(attribute='sub_part__name', readonly=True)
def dehydrate_max_cost(self, item):
"""Render maximum cost value for the BOM line item"""
# Description of the sub-part
part_description = Field(attribute='sub_part__description', readonly=True)
max_price = item.sub_part.pricing.overall_max if item.sub_part.pricing else None
# Is the sub-part itself an assembly?
sub_assembly = Field(attribute='sub_part__assembly', readonly=True)
if max_price is not None:
return float(max_price.amount) * float(item.quantity)
def dehydrate_quantity(self, item):
"""Special consideration for the 'quantity' field on data export. We do not want a spreadsheet full of "1.0000" (we'd rather "1")
@ -197,34 +253,43 @@ class BomItemResource(InvenTreeResource):
def before_export(self, queryset, *args, **kwargs):
"""Perform before exporting data"""
self.is_importing = kwargs.get('importing', False)
self.include_pricing = kwargs.pop('include_pricing', False)
def get_fields(self, **kwargs):
"""If we are exporting for the purposes of generating a 'bom-import' template, there are some fields which we are not interested in."""
fields = super().get_fields(**kwargs)
# If we are not generating an "import" template,
# just return the complete list of fields
if not getattr(self, 'is_importing', False):
return fields
is_importing = getattr(self, 'is_importing', False)
include_pricing = getattr(self, 'include_pricing', False)
# Otherwise, remove some fields we are not interested in
to_remove = []
if is_importing or not include_pricing:
# Remove pricing fields in this instance
to_remove += [
'sub_part__pricing__overall_min',
'sub_part__pricing__overall_max',
]
if is_importing:
to_remove += [
'level',
'pk',
'part',
'part__IPN',
'part__name',
'sub_part__name',
'sub_part__description',
'sub_part__assembly'
]
idx = 0
to_remove = [
'level',
'bom_id',
'parent_part_id',
'parent_part_ipn',
'parent_part_name',
'part_description',
'sub_assembly'
]
while idx < len(fields):
if fields[idx].column_name.lower() in to_remove:
if fields[idx].attribute in to_remove:
del fields[idx]
else:
idx += 1
@ -334,3 +399,4 @@ admin.site.register(models.PartCategoryParameterTemplate, PartCategoryParameterA
admin.site.register(models.PartTestTemplate, PartTestTemplateAdmin)
admin.site.register(models.PartSellPriceBreak, PartSellPriceBreakAdmin)
admin.site.register(models.PartInternalPriceBreak, PartInternalPriceBreakAdmin)
admin.site.register(models.PartPricing, PartPricingAdmin)

View File

@ -4,23 +4,19 @@ import functools
from decimal import Decimal, InvalidOperation
from django.db import transaction
from django.db.models import Avg, Count, F, Max, Min, Q
from django.db.models import Count, F, Q
from django.http import JsonResponse
from django.urls import include, path, re_path
from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as rest_filters
from django_filters.rest_framework import DjangoFilterBackend
from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
from rest_framework import filters, serializers, status
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
import order.models
from build.models import Build, BuildItem
from common.models import InvenTreeSetting
from company.models import Company, ManufacturerPart, SupplierPart
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
ListCreateDestroyAPIView)
@ -33,7 +29,7 @@ from InvenTree.mixins import (CreateAPI, CustomRetrieveUpdateDestroyAPI,
UpdateAPI)
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
SalesOrderStatus)
from part.admin import PartResource
from part.admin import PartCategoryResource, PartResource
from plugin.serializers import MetadataSerializer
from stock.models import StockItem, StockLocation
@ -45,7 +41,7 @@ from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
PartTestTemplate)
class CategoryList(ListCreateAPI):
class CategoryList(APIDownloadMixin, ListCreateAPI):
"""API endpoint for accessing a list of PartCategory objects.
- GET: Return a list of PartCategory objects
@ -55,6 +51,15 @@ class CategoryList(ListCreateAPI):
queryset = PartCategory.objects.all()
serializer_class = part_serializers.CategorySerializer
def download_queryset(self, queryset, export_format):
"""Download the filtered queryset as a data file"""
dataset = PartCategoryResource().export(queryset=queryset)
filedata = dataset.export(export_format)
filename = f"InvenTree_Categories.{export_format}"
return DownloadFile(filedata, filename)
def get_queryset(self, *args, **kwargs):
"""Return an annotated queryset for the CategoryList endpoint"""
@ -720,6 +725,27 @@ class PartMetadata(RetrieveUpdateAPI):
queryset = Part.objects.all()
class PartPricingDetail(RetrieveUpdateAPI):
"""API endpoint for viewing part pricing data"""
serializer_class = part_serializers.PartPricingSerializer
queryset = Part.objects.all()
def get_object(self):
"""Return the PartPricing object associated with the linked Part"""
part = super().get_object()
return part.pricing
def _get_serializer(self, *args, **kwargs):
"""Return a part pricing serializer object"""
part = self.get_object()
kwargs['instance'] = part.pricing
return self.serializer_class(**kwargs)
class PartSerialNumberDetail(RetrieveAPI):
"""API endpoint for returning extra serial number information about a particular part."""
@ -1014,6 +1040,23 @@ class PartFilter(rest_filters.FilterSet):
queryset = queryset.filter(id__in=[p.pk for p in bom_parts])
return queryset
has_pricing = rest_filters.BooleanFilter(label="Has Pricing", method="filter_has_pricing")
def filter_has_pricing(self, queryset, name, value):
"""Filter the queryset based on whether pricing information is available for the sub_part"""
value = str2bool(value)
q_a = Q(pricing_data=None)
q_b = Q(pricing_data__overall_min=None, pricing_data__overall_max=None)
if value:
queryset = queryset.exclude(q_a | q_b)
else:
queryset = queryset.filter(q_a | q_b)
return queryset
is_template = rest_filters.BooleanFilter()
assembly = rest_filters.BooleanFilter()
@ -1063,7 +1106,7 @@ class PartList(APIDownloadMixin, ListCreateAPI):
# Ensure the request context is passed through
kwargs['context'] = self.get_serializer_context()
# Pass a list of "starred" parts fo the current user to the serializer
# Pass a list of "starred" parts to the current user to the serializer
# We do this to reduce the number of database queries required!
if self.starred_parts is None and self.request is not None:
self.starred_parts = [star.part for star in self.request.user.starred_parts.all()]
@ -1480,7 +1523,7 @@ class PartList(APIDownloadMixin, ListCreateAPI):
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
InvenTreeOrderingFilter,
]
ordering_fields = [
@ -1717,6 +1760,23 @@ class BomFilter(rest_filters.FilterSet):
return queryset
has_pricing = rest_filters.BooleanFilter(label="Has Pricing", method="filter_has_pricing")
def filter_has_pricing(self, queryset, name, value):
"""Filter the queryset based on whether pricing information is available for the sub_part"""
value = str2bool(value)
q_a = Q(sub_part__pricing_data=None)
q_b = Q(sub_part__pricing_data__overall_min=None, sub_part__pricing_data__overall_max=None)
if value:
queryset = queryset.exclude(q_a | q_b)
else:
queryset = queryset.filter(q_a | q_b)
return queryset
class BomList(ListCreateDestroyAPIView):
"""API endpoint for accessing a list of BomItem objects.
@ -1761,7 +1821,6 @@ class BomList(ListCreateDestroyAPIView):
If requested, extra detail fields are annotated to the queryset:
- part_detail
- sub_part_detail
- include_pricing
"""
# Do we wish to include extra detail?
@ -1775,12 +1834,6 @@ class BomList(ListCreateDestroyAPIView):
except AttributeError:
pass
try:
# Include or exclude pricing information in the serialized data
kwargs['include_pricing'] = self.include_pricing()
except AttributeError:
pass
# Ensure the request context is passed through!
kwargs['context'] = self.get_serializer_context()
@ -1850,73 +1903,6 @@ class BomList(ListCreateDestroyAPIView):
except (ValueError, Part.DoesNotExist):
pass
if self.include_pricing():
queryset = self.annotate_pricing(queryset)
return queryset
def include_pricing(self):
"""Determine if pricing information should be included in the response."""
pricing_default = InvenTreeSetting.get_setting('PART_SHOW_PRICE_IN_BOM')
return str2bool(self.request.query_params.get('include_pricing', pricing_default))
def annotate_pricing(self, queryset):
"""Add part pricing information to the queryset."""
# Annotate with purchase prices
queryset = queryset.annotate(
purchase_price_min=Min('sub_part__stock_items__purchase_price'),
purchase_price_max=Max('sub_part__stock_items__purchase_price'),
purchase_price_avg=Avg('sub_part__stock_items__purchase_price'),
)
# Get values for currencies
currencies = queryset.annotate(
purchase_price=F('sub_part__stock_items__purchase_price'),
purchase_price_currency=F('sub_part__stock_items__purchase_price_currency'),
).values('pk', 'sub_part', 'purchase_price', 'purchase_price_currency')
def convert_price(price, currency, decimal_places=4):
"""Convert price field, returns Money field."""
price_adjusted = None
# Get default currency from settings
default_currency = InvenTreeSetting.get_setting('INVENTREE_DEFAULT_CURRENCY')
if price:
if currency and default_currency:
try:
# Get adjusted price
price_adjusted = convert_money(Money(price, currency), default_currency)
except MissingRate:
# No conversion rate set
price_adjusted = Money(price, currency)
else:
# Currency exists
if currency:
price_adjusted = Money(price, currency)
# Default currency exists
if default_currency:
price_adjusted = Money(price, default_currency)
if price_adjusted and decimal_places:
price_adjusted.decimal_places = decimal_places
return price_adjusted
# Convert prices to default currency (using backend conversion rates)
for bom_item in queryset:
# Find associated currency (select first found)
purchase_price_currency = None
for currency_item in currencies:
if currency_item['pk'] == bom_item.pk and currency_item['sub_part'] == bom_item.sub_part.pk and currency_item['purchase_price']:
purchase_price_currency = currency_item['purchase_price_currency']
break
# Convert prices
bom_item.purchase_price_min = convert_price(bom_item.purchase_price_min, purchase_price_currency)
bom_item.purchase_price_max = convert_price(bom_item.purchase_price_max, purchase_price_currency)
bom_item.purchase_price_avg = convert_price(bom_item.purchase_price_avg, purchase_price_currency)
return queryset
filter_backends = [
@ -2145,6 +2131,9 @@ part_api_urls = [
# Part metadata
re_path(r'^metadata/', PartMetadata.as_view(), name='api-part-metadata'),
# Part pricing
re_path(r'^pricing/', PartPricingDetail.as_view(), name='api-part-pricing'),
# Part detail endpoint
re_path(r'^.*$', PartDetail.as_view(), name='api-part-detail'),
])),

View File

@ -5,7 +5,7 @@ import logging
from django.apps import AppConfig
from django.db.utils import OperationalError, ProgrammingError
from InvenTree.ready import canAppAccessDatabase
from InvenTree.ready import canAppAccessDatabase, isImportingData
logger = logging.getLogger("inventree")
@ -18,6 +18,7 @@ class PartConfig(AppConfig):
"""This function is called whenever the Part app is loaded."""
if canAppAccessDatabase():
self.update_trackable_status()
self.reset_part_pricing_flags()
def update_trackable_status(self):
"""Check for any instances where a trackable part is used in the BOM for a non-trackable part.
@ -37,3 +38,24 @@ class PartConfig(AppConfig):
except (OperationalError, ProgrammingError): # pragma: no cover
# Exception if the database has not been migrated yet
pass
def reset_part_pricing_flags(self):
"""Performed on startup, to ensure that all pricing objects are in a "good" state.
Prevents issues with state machine if the server is restarted mid-update
"""
from .models import PartPricing
if isImportingData():
return
items = PartPricing.objects.filter(scheduled_for_update=True)
if items.count() > 0:
# Find any pricing objects which have the 'scheduled_for_update' flag set
print(f"Resetting update flags for {items.count()} pricing objects...")
for pricing in items:
pricing.scheduled_for_update = False
pricing.save()

View File

@ -8,7 +8,8 @@ from collections import OrderedDict
from django.utils.translation import gettext as _
from company.models import ManufacturerPart, SupplierPart
from InvenTree.helpers import DownloadFile, GetExportFormats, normalize
from InvenTree.helpers import (DownloadFile, GetExportFormats, normalize,
str2bool)
from .admin import BomItemResource
from .models import BomItem, Part
@ -42,7 +43,7 @@ def MakeBomTemplate(fmt):
return DownloadFile(data, filename)
def ExportBom(part: Part, fmt='csv', cascade: bool = False, max_levels: int = None, parameter_data=False, stock_data=False, supplier_data=False, manufacturer_data=False):
def ExportBom(part: Part, fmt='csv', cascade: bool = False, max_levels: int = None, **kwargs):
"""Export a BOM (Bill of Materials) for a given part.
Args:
@ -50,14 +51,24 @@ def ExportBom(part: Part, fmt='csv', cascade: bool = False, max_levels: int = No
fmt (str, optional): file format. Defaults to 'csv'.
cascade (bool, optional): If True, multi-level BOM output is supported. Otherwise, a flat top-level-only BOM is exported.. Defaults to False.
max_levels (int, optional): Levels of items that should be included. None for np sublevels. Defaults to None.
kwargs:
parameter_data (bool, optional): Additonal data that should be added. Defaults to False.
stock_data (bool, optional): Additonal data that should be added. Defaults to False.
supplier_data (bool, optional): Additonal data that should be added. Defaults to False.
manufacturer_data (bool, optional): Additonal data that should be added. Defaults to False.
pricing_data (bool, optional): Include pricing data in exported BOM. Defaults to False
Returns:
StreamingHttpResponse: Response that can be passed to the endpoint
"""
parameter_data = str2bool(kwargs.get('parameter_data', False))
stock_data = str2bool(kwargs.get('stock_data', False))
supplier_data = str2bool(kwargs.get('supplier_data', False))
manufacturer_data = str2bool(kwargs.get('manufacturer_data', False))
pricing_data = str2bool(kwargs.get('pricing_data', False))
if not IsValidBOMFormat(fmt):
fmt = 'csv'
@ -85,7 +96,11 @@ def ExportBom(part: Part, fmt='csv', cascade: bool = False, max_levels: int = No
add_items(top_level_items, 1, cascade)
dataset = BomItemResource().export(queryset=bom_items, cascade=cascade)
dataset = BomItemResource().export(
queryset=bom_items,
cascade=cascade,
include_pricing=pricing_data,
)
def add_columns_to_dataset(columns, column_size):
try:

View File

@ -0,0 +1,76 @@
# Generated by Django 3.2.16 on 2022-11-12 01:28
import InvenTree.fields
import common.settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import djmoney.models.fields
import djmoney.models.validators
class Migration(migrations.Migration):
dependencies = [
('part', '0088_alter_partparametertemplate_name'),
]
operations = [
migrations.AlterField(
model_name='part',
name='base_cost',
field=models.DecimalField(decimal_places=6, default=0, help_text='Minimum charge (e.g. stocking fee)', max_digits=19, validators=[django.core.validators.MinValueValidator(0)], verbose_name='base cost'),
),
migrations.AlterField(
model_name='partinternalpricebreak',
name='price',
field=InvenTree.fields.InvenTreeModelMoneyField(currency_choices=[], decimal_places=6, default_currency='', help_text='Unit price at specified quantity', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price'),
),
migrations.AlterField(
model_name='partsellpricebreak',
name='price',
field=InvenTree.fields.InvenTreeModelMoneyField(currency_choices=[], decimal_places=6, default_currency='', help_text='Unit price at specified quantity', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price'),
),
migrations.CreateModel(
name='PartPricing',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('currency', models.CharField(choices=[('AUD', 'Australian Dollar'), ('CAD', 'Canadian Dollar'), ('CNY', 'Chinese Yuan'), ('EUR', 'Euro'), ('GBP', 'British Pound'), ('JPY', 'Japanese Yen'), ('NZD', 'New Zealand Dollar'), ('USD', 'US Dollar')], default=common.settings.currency_code_default, help_text='Currency used to cache pricing calculations', max_length=10, verbose_name='Currency')),
('updated', models.DateTimeField(auto_now=True, help_text='Timestamp of last pricing update', verbose_name='Updated')),
('scheduled_for_update', models.BooleanField(default=False)),
('bom_cost_min_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('bom_cost_min', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Minimum cost of component parts', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Minimum BOM Cost')),
('bom_cost_max_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('bom_cost_max', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Maximum cost of component parts', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Maximum BOM Cost')),
('purchase_cost_min_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('purchase_cost_min', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Minimum historical purchase cost', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Minimum Purchase Cost')),
('purchase_cost_max_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('purchase_cost_max', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Maximum historical purchase cost', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Maximum Purchase Cost')),
('internal_cost_min_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('internal_cost_min', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Minimum cost based on internal price breaks', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Minimum Internal Price')),
('internal_cost_max_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('internal_cost_max', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Maximum cost based on internal price breaks', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Maximum Internal Price')),
('supplier_price_min_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('supplier_price_min', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Minimum price of part from external suppliers', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Minimum Supplier Price')),
('supplier_price_max_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('supplier_price_max', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Maximum price of part from external suppliers', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Maximum Supplier Price')),
('variant_cost_min_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('variant_cost_min', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Calculated minimum cost of variant parts', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Minimum Variant Cost')),
('variant_cost_max_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('variant_cost_max', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Calculated maximum cost of variant parts', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Maximum Variant Cost')),
('overall_min_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('overall_min', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Calculated overall minimum cost', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Minimum Cost')),
('overall_max_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('overall_max', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Calculated overall maximum cost', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Maximum Cost')),
('sale_price_min_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('sale_price_min', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Minimum sale price based on price breaks', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Minimum Sale Price')),
('sale_price_max_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('sale_price_max', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Maximum sale price based on price breaks', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Maximum Sale Price')),
('sale_history_min_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('sale_history_min', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Minimum historical sale price', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Minimum Sale Cost')),
('sale_history_max_currency', djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3)),
('sale_history_max', InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Maximum historical sale price', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Maximum Sale Cost')),
('part', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='pricing_data', to='part.part', verbose_name='Part')),
],
),
]

View File

@ -15,7 +15,7 @@ from django.core.validators import MinValueValidator
from django.db import models, transaction
from django.db.models import ExpressionWrapper, F, Q, Sum, UniqueConstraint
from django.db.models.functions import Coalesce
from django.db.models.signals import post_save
from django.db.models.signals import post_delete, post_save
from django.db.utils import IntegrityError
from django.dispatch import receiver
from django.urls import reverse
@ -24,6 +24,7 @@ from django.utils.translation import gettext_lazy as _
from django_cleanup import cleanup
from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
from jinja2 import Template
from mptt.exceptions import InvalidMove
from mptt.managers import TreeManager
@ -31,6 +32,8 @@ from mptt.models import MPTTModel, TreeForeignKey
from stdimage.models import StdImageField
import common.models
import common.settings
import InvenTree.fields
import InvenTree.ready
import InvenTree.tasks
import part.filters as part_filters
@ -308,6 +311,7 @@ class PartManager(TreeManager):
"""Perform default prefetch operations when accessing Part model from the database"""
return super().get_queryset().prefetch_related(
'category',
'pricing_data',
'category__parent',
'stock_items',
'builds',
@ -1649,15 +1653,25 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
"""Return the number of supplier parts available for this part."""
return self.supplier_parts.count()
@property
def has_complete_bom_pricing(self):
"""Return true if there is pricing information for each item in the BOM."""
use_internal = common.models.InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
for item in self.get_bom_items().select_related('sub_part'):
if item.sub_part.get_price_range(internal=use_internal) is None:
return False
def update_pricing(self):
"""Recalculate cached pricing for this Part instance"""
return True
self.pricing.update_pricing()
@property
def pricing(self):
"""Return the PartPricing information for this Part instance.
If there is no PartPricing database entry defined for this Part,
it will first be created, and then returned.
"""
try:
pricing = PartPricing.objects.get(part=self)
except PartPricing.DoesNotExist:
pricing = PartPricing(part=self)
return pricing
def get_price_info(self, quantity=1, buy=True, bom=True, internal=False):
"""Return a simplified pricing string for this part.
@ -1800,7 +1814,7 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
max(buy_price_range[1], bom_price_range[1])
)
base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], verbose_name=_('base cost'), help_text=_('Minimum charge (e.g. stocking fee)'))
base_cost = models.DecimalField(max_digits=19, decimal_places=6, default=0, validators=[MinValueValidator(0)], verbose_name=_('base cost'), help_text=_('Minimum charge (e.g. stocking fee)'))
multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], verbose_name=_('multiple'), help_text=_('Sell multiple'))
@ -2199,6 +2213,590 @@ def after_save_part(sender, instance: Part, created, **kwargs):
InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance)
class PartPricing(models.Model):
"""Model for caching min/max pricing information for a particular Part
It is prohibitively expensive to calculate min/max pricing for a part "on the fly".
As min/max pricing does not change very often, we pre-calculate and cache these values.
Whenever pricing is updated, these values are re-calculated and stored.
Pricing information is cached for:
- BOM cost (min / max cost of component items)
- Purchase cost (based on purchase history)
- Internal cost (based on user-specified InternalPriceBreak data)
- Supplier price (based on supplier part data)
- Variant price (min / max cost of any variants)
- Overall best / worst (based on the values listed above)
- Sale price break min / max values
- Historical sale pricing min / max values
Note that this pricing information does not take "quantity" into account:
- This provides a simple min / max pricing range, which is quite valuable in a lot of situations
- Quantity pricing still needs to be calculated
- Quantity pricing can be viewed from the part detail page
- Detailed pricing information is very context specific in any case
"""
@property
def is_valid(self):
"""Return True if the cached pricing is valid"""
return self.updated is not None
def convert(self, money):
"""Attempt to convert money value to default currency.
If a MissingRate error is raised, ignore it and return None
"""
if money is None:
return None
target_currency = currency_code_default()
try:
result = convert_money(money, target_currency)
except MissingRate:
logger.warning(f"No currency conversion rate available for {money.currency} -> {target_currency}")
result = None
return result
def schedule_for_update(self, counter: int = 0):
"""Schedule this pricing to be updated"""
if self.pk is None:
self.save()
self.refresh_from_db()
if self.scheduled_for_update:
# Ignore if the pricing is already scheduled to be updated
logger.info(f"Pricing for {self.part} already scheduled for update - skipping")
return
if counter > 25:
# Prevent infinite recursion / stack depth issues
logger.info(counter, f"Skipping pricing update for {self.part} - maximum depth exceeded")
return
self.scheduled_for_update = True
self.save()
import part.tasks as part_tasks
# Offload task to update the pricing
# Force async, to prevent running in the foreground
InvenTree.tasks.offload_task(
part_tasks.update_part_pricing,
self,
counter=counter,
force_async=True
)
def update_pricing(self, counter: int = 0):
"""Recalculate all cost data for the referenced Part instance"""
if self.pk is not None:
self.refresh_from_db()
self.update_bom_cost(save=False)
self.update_purchase_cost(save=False)
self.update_internal_cost(save=False)
self.update_supplier_cost(save=False)
self.update_variant_cost(save=False)
self.update_sale_cost(save=False)
# Clear scheduling flag
self.scheduled_for_update = False
# Note: save method calls update_overall_cost
try:
self.save()
except IntegrityError:
# Background worker processes may try to concurrently update
pass
# Update parent assemblies and templates
self.update_assemblies(counter)
self.update_templates(counter)
def update_assemblies(self, counter: int = 0):
"""Schedule updates for any assemblies which use this part"""
# If the linked Part is used in any assemblies, schedule a pricing update for those assemblies
used_in_parts = self.part.get_used_in()
for p in used_in_parts:
p.pricing.schedule_for_update(counter + 1)
def update_templates(self, counter: int = 0):
"""Schedule updates for any template parts above this part"""
templates = self.part.get_ancestors(include_self=False)
for p in templates:
p.pricing.schedule_for_update(counter + 1)
def save(self, *args, **kwargs):
"""Whenever pricing model is saved, automatically update overall prices"""
# Update the currency which was used to perform the calculation
self.currency = currency_code_default()
self.update_overall_cost()
super().save(*args, **kwargs)
def update_bom_cost(self, save=True):
"""Recalculate BOM cost for the referenced Part instance.
Iterate through the Bill of Materials, and calculate cumulative pricing:
cumulative_min: The sum of minimum costs for each line in the BOM
cumulative_max: The sum of maximum costs for each line in the BOM
Note: The cumulative costs are calculated based on the specified default currency
"""
if not self.part.assembly:
# Not an assembly - no BOM pricing
self.bom_cost_min = None
self.bom_cost_max = None
if save:
self.save()
# Short circuit - no further operations required
return
currency_code = common.settings.currency_code_default()
cumulative_min = Money(0, currency_code)
cumulative_max = Money(0, currency_code)
any_min_elements = False
any_max_elements = False
for bom_item in self.part.get_bom_items():
# Loop through each BOM item which is used to assemble this part
bom_item_min = None
bom_item_max = None
for sub_part in bom_item.get_valid_parts_for_allocation():
# Check each part which *could* be used
sub_part_pricing = sub_part.pricing
sub_part_min = self.convert(sub_part_pricing.overall_min)
sub_part_max = self.convert(sub_part_pricing.overall_max)
if sub_part_min is not None:
if bom_item_min is None or sub_part_min < bom_item_min:
bom_item_min = sub_part_min
if sub_part_max is not None:
if bom_item_max is None or sub_part_max > bom_item_max:
bom_item_max = sub_part_max
# Update cumulative totals
if bom_item_min is not None:
bom_item_min *= bom_item.quantity
cumulative_min += self.convert(bom_item_min)
any_min_elements = True
if bom_item_max is not None:
bom_item_max *= bom_item.quantity
cumulative_max += self.convert(bom_item_max)
any_max_elements = True
if any_min_elements:
self.bom_cost_min = cumulative_min
else:
self.bom_cost_min = None
if any_max_elements:
self.bom_cost_max = cumulative_max
else:
self.bom_cost_max = None
if save:
self.save()
def update_purchase_cost(self, save=True):
"""Recalculate historical purchase cost for the referenced Part instance.
Purchase history only takes into account "completed" purchase orders.
"""
# Find all line items for completed orders which reference this part
line_items = OrderModels.PurchaseOrderLineItem.objects.filter(
order__status=PurchaseOrderStatus.COMPLETE,
received__gt=0,
part__part=self.part,
)
# Exclude line items which do not have an associated price
line_items = line_items.exclude(purchase_price=None)
purchase_min = None
purchase_max = None
for line in line_items:
if line.purchase_price is None:
continue
# Take supplier part pack size into account
purchase_cost = self.convert(line.purchase_price / line.part.pack_size)
if purchase_cost is None:
continue
if purchase_min is None or purchase_cost < purchase_min:
purchase_min = purchase_cost
if purchase_max is None or purchase_cost > purchase_max:
purchase_max = purchase_cost
self.purchase_cost_min = purchase_min
self.purchase_cost_max = purchase_max
if save:
self.save()
def update_internal_cost(self, save=True):
"""Recalculate internal cost for the referenced Part instance"""
min_int_cost = None
max_int_cost = None
if InvenTreeSetting.get_setting('PART_INTERNAL_PRICE', False, cache=False):
# Only calculate internal pricing if internal pricing is enabled
for pb in self.part.internalpricebreaks.all():
cost = self.convert(pb.price)
if cost is None:
# Ignore if cost could not be converted for some reason
continue
if min_int_cost is None or cost < min_int_cost:
min_int_cost = cost
if max_int_cost is None or cost > max_int_cost:
max_int_cost = cost
self.internal_cost_min = min_int_cost
self.internal_cost_max = max_int_cost
if save:
self.save()
def update_supplier_cost(self, save=True):
"""Recalculate supplier cost for the referenced Part instance.
- The limits are simply the lower and upper bounds of available SupplierPriceBreaks
- We do not take "quantity" into account here
"""
min_sup_cost = None
max_sup_cost = None
if self.part.purchaseable:
# Iterate through each available SupplierPart instance
for sp in self.part.supplier_parts.all():
# Iterate through each available SupplierPriceBreak instance
for pb in sp.pricebreaks.all():
if pb.price is None:
continue
# Ensure we take supplier part pack size into account
cost = self.convert(pb.price / sp.pack_size)
if cost is None:
continue
if min_sup_cost is None or cost < min_sup_cost:
min_sup_cost = cost
if max_sup_cost is None or cost > max_sup_cost:
max_sup_cost = cost
self.supplier_price_min = min_sup_cost
self.supplier_price_max = max_sup_cost
if save:
self.save()
def update_variant_cost(self, save=True):
"""Update variant cost values.
Here we track the min/max costs of any variant parts.
"""
variant_min = None
variant_max = None
if self.part.is_template:
variants = self.part.get_descendants(include_self=False)
for v in variants:
v_min = self.convert(v.pricing.overall_min)
v_max = self.convert(v.pricing.overall_max)
if v_min is not None:
if variant_min is None or v_min < variant_min:
variant_min = v_min
if v_max is not None:
if variant_max is None or v_max > variant_max:
variant_max = v_max
self.variant_cost_min = variant_min
self.variant_cost_max = variant_max
if save:
self.save()
def update_overall_cost(self):
"""Update overall cost values.
Here we simply take the minimum / maximum values of the other calculated fields.
"""
overall_min = None
overall_max = None
# Calculate overall minimum cost
for cost in [
self.bom_cost_min,
self.purchase_cost_min,
self.internal_cost_min,
self.supplier_price_min,
self.variant_cost_min,
]:
if cost is None:
continue
# Ensure we are working in a common currency
cost = self.convert(cost)
if overall_min is None or cost < overall_min:
overall_min = cost
# Calculate overall maximum cost
for cost in [
self.bom_cost_max,
self.purchase_cost_max,
self.internal_cost_max,
self.supplier_price_max,
self.variant_cost_max,
]:
if cost is None:
continue
# Ensure we are working in a common currency
cost = self.convert(cost)
if overall_max is None or cost > overall_max:
overall_max = cost
if InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False, cache=False):
# Check if internal pricing should override other pricing
if self.internal_cost_min is not None:
overall_min = self.internal_cost_min
if self.internal_cost_max is not None:
overall_max = self.internal_cost_max
self.overall_min = overall_min
self.overall_max = overall_max
def update_sale_cost(self, save=True):
"""Recalculate sale cost data"""
# Iterate through the sell price breaks
min_sell_price = None
max_sell_price = None
for pb in self.part.salepricebreaks.all():
cost = self.convert(pb.price)
if cost is None:
continue
if min_sell_price is None or cost < min_sell_price:
min_sell_price = cost
if max_sell_price is None or cost > max_sell_price:
max_sell_price = cost
# Record min/max values
self.sale_price_min = min_sell_price
self.sale_price_max = max_sell_price
min_sell_history = None
max_sell_history = None
# Find all line items for shipped sales orders which reference this part
line_items = OrderModels.SalesOrderLineItem.objects.filter(
order__status=SalesOrderStatus.SHIPPED,
part=self.part
)
# Exclude line items which do not have associated pricing data
line_items = line_items.exclude(sale_price=None)
for line in line_items:
cost = self.convert(line.sale_price)
if cost is None:
continue
if min_sell_history is None or cost < min_sell_history:
min_sell_history = cost
if max_sell_history is None or cost > max_sell_history:
max_sell_history = cost
self.sale_history_min = min_sell_history
self.sale_history_max = max_sell_history
if save:
self.save()
currency = models.CharField(
default=currency_code_default,
max_length=10,
verbose_name=_('Currency'),
help_text=_('Currency used to cache pricing calculations'),
choices=common.settings.currency_code_mappings(),
)
updated = models.DateTimeField(
verbose_name=_('Updated'),
help_text=_('Timestamp of last pricing update'),
auto_now=True
)
scheduled_for_update = models.BooleanField(
default=False,
)
part = models.OneToOneField(
Part,
on_delete=models.CASCADE,
related_name='pricing_data',
verbose_name=_('Part'),
)
bom_cost_min = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Minimum BOM Cost'),
help_text=_('Minimum cost of component parts')
)
bom_cost_max = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Maximum BOM Cost'),
help_text=_('Maximum cost of component parts'),
)
purchase_cost_min = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Minimum Purchase Cost'),
help_text=_('Minimum historical purchase cost'),
)
purchase_cost_max = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Maximum Purchase Cost'),
help_text=_('Maximum historical purchase cost'),
)
internal_cost_min = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Minimum Internal Price'),
help_text=_('Minimum cost based on internal price breaks'),
)
internal_cost_max = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Maximum Internal Price'),
help_text=_('Maximum cost based on internal price breaks'),
)
supplier_price_min = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Minimum Supplier Price'),
help_text=_('Minimum price of part from external suppliers'),
)
supplier_price_max = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Maximum Supplier Price'),
help_text=_('Maximum price of part from external suppliers'),
)
variant_cost_min = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Minimum Variant Cost'),
help_text=_('Calculated minimum cost of variant parts'),
)
variant_cost_max = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Maximum Variant Cost'),
help_text=_('Calculated maximum cost of variant parts'),
)
overall_min = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Minimum Cost'),
help_text=_('Calculated overall minimum cost'),
)
overall_max = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Maximum Cost'),
help_text=_('Calculated overall maximum cost'),
)
sale_price_min = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Minimum Sale Price'),
help_text=_('Minimum sale price based on price breaks'),
)
sale_price_max = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Maximum Sale Price'),
help_text=_('Maximum sale price based on price breaks'),
)
sale_history_min = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Minimum Sale Cost'),
help_text=_('Minimum historical sale price'),
)
sale_history_max = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True,
verbose_name=_('Maximum Sale Cost'),
help_text=_('Maximum historical sale price'),
)
class PartAttachment(InvenTreeAttachment):
"""Model for storing file attachments against a Part object."""
@ -2886,7 +3484,7 @@ class BomItem(DataImportMixin, models.Model):
def price_range(self, internal=False):
"""Return the price-range for this BOM item."""
# get internal price setting
use_internal = common.models.InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
use_internal = common.models.InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False, cache=False)
prange = self.sub_part.get_price_range(self.quantity, internal=use_internal and internal)
if prange is None:
@ -2904,6 +3502,28 @@ class BomItem(DataImportMixin, models.Model):
return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax)
@receiver(post_save, sender=BomItem, dispatch_uid='post_save_bom_item')
@receiver(post_save, sender=PartSellPriceBreak, dispatch_uid='post_save_sale_price_break')
@receiver(post_save, sender=PartInternalPriceBreak, dispatch_uid='post_save_internal_price_break')
def update_pricing_after_edit(sender, instance, created, **kwargs):
"""Callback function when a part price break is created or updated"""
# Update part pricing *unless* we are importing data
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
instance.part.pricing.schedule_for_update()
@receiver(post_delete, sender=BomItem, dispatch_uid='post_delete_bom_item')
@receiver(post_delete, sender=PartSellPriceBreak, dispatch_uid='post_delete_sale_price_break')
@receiver(post_delete, sender=PartInternalPriceBreak, dispatch_uid='post_delete_internal_price_break')
def update_pricing_after_delete(sender, instance, **kwargs):
"""Callback function when a part price break is deleted"""
# Update part pricing *unless* we are importing data
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
instance.part.pricing.schedule_for_update()
class BomItemSubstitute(models.Model):
"""A BomItemSubstitute provides a specification for alternative parts, which can be used in a bill of materials.

View File

@ -11,10 +11,10 @@ from django.db.models.functions import Coalesce
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from djmoney.contrib.django_rest_framework import MoneyField
from rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum
import InvenTree.helpers
import part.filters
from common.settings import currency_code_default, currency_code_mappings
from InvenTree.serializers import (DataFileExtractSerializer,
@ -30,8 +30,8 @@ from InvenTree.status_codes import BuildStatus
from .models import (BomItem, BomItemSubstitute, Part, PartAttachment,
PartCategory, PartCategoryParameterTemplate,
PartInternalPriceBreak, PartParameter,
PartParameterTemplate, PartRelated, PartSellPriceBreak,
PartStar, PartTestTemplate)
PartParameterTemplate, PartPricing, PartRelated,
PartSellPriceBreak, PartStar, PartTestTemplate)
class CategorySerializer(InvenTreeModelSerializer):
@ -154,8 +154,6 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
help_text=_('Purchase currency of this stock item'),
)
price_string = serializers.CharField(source='price', read_only=True)
class Meta:
"""Metaclass defining serializer fields"""
model = PartSellPriceBreak
@ -165,7 +163,6 @@ class PartSalePriceSerializer(InvenTreeModelSerializer):
'quantity',
'price',
'price_currency',
'price_string',
]
@ -185,8 +182,6 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
help_text=_('Purchase currency of this stock item'),
)
price_string = serializers.CharField(source='price', read_only=True)
class Meta:
"""Metaclass defining serializer fields"""
model = PartInternalPriceBreak
@ -196,7 +191,6 @@ class PartInternalPriceSerializer(InvenTreeModelSerializer):
'quantity',
'price',
'price_currency',
'price_string',
]
@ -421,6 +415,10 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
# PrimaryKeyRelated fields (Note: enforcing field type here results in much faster queries, somehow...)
category = serializers.PrimaryKeyRelatedField(queryset=PartCategory.objects.all())
# Pricing fields
pricing_min = InvenTreeMoneySerializer(source='pricing_data.overall_min', allow_null=True, read_only=True)
pricing_max = InvenTreeMoneySerializer(source='pricing_data.overall_max', allow_null=True, read_only=True)
parameters = PartParameterSerializer(
many=True,
read_only=True,
@ -471,6 +469,8 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
'units',
'variant_of',
'virtual',
'pricing_min',
'pricing_max',
]
read_only_fields = [
@ -503,6 +503,84 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
return self.instance
class PartPricingSerializer(InvenTreeModelSerializer):
"""Serializer for Part pricing information"""
currency = serializers.CharField(allow_null=True, read_only=True)
updated = serializers.DateTimeField(allow_null=True, read_only=True)
scheduled_for_update = serializers.BooleanField(read_only=True)
# Custom serializers
bom_cost_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
bom_cost_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
purchase_cost_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
purchase_cost_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
internal_cost_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
internal_cost_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
supplier_price_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
supplier_price_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
variant_cost_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
variant_cost_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
overall_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
overall_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
sale_price_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
sale_price_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
sale_history_min = InvenTreeMoneySerializer(allow_null=True, read_only=True)
sale_history_max = InvenTreeMoneySerializer(allow_null=True, read_only=True)
update = serializers.BooleanField(
write_only=True,
label=_('Update'),
help_text=_('Update pricing for this part'),
default=False,
required=False,
)
class Meta:
"""Metaclass defining serializer fields"""
model = PartPricing
fields = [
'currency',
'updated',
'scheduled_for_update',
'bom_cost_min',
'bom_cost_max',
'purchase_cost_min',
'purchase_cost_max',
'internal_cost_min',
'internal_cost_max',
'supplier_price_min',
'supplier_price_max',
'variant_cost_min',
'variant_cost_max',
'overall_min',
'overall_max',
'sale_price_min',
'sale_price_max',
'sale_history_min',
'sale_history_max',
'update',
]
def save(self):
"""Called when the serializer is saved"""
data = self.validated_data
if InvenTree.helpers.str2bool(data.get('update', False)):
# Update part pricing
pricing = self.instance
pricing.update_pricing()
class PartRelationSerializer(InvenTreeModelSerializer):
"""Serializer for a PartRelated model."""
@ -558,8 +636,6 @@ class BomItemSubstituteSerializer(InvenTreeModelSerializer):
class BomItemSerializer(InvenTreeModelSerializer):
"""Serializer for BomItem object."""
price_range = serializers.CharField(read_only=True)
quantity = InvenTreeDecimalField(required=True)
def validate_quantity(self, quantity):
@ -581,16 +657,12 @@ class BomItemSerializer(InvenTreeModelSerializer):
validated = serializers.BooleanField(read_only=True, source='is_line_valid')
purchase_price_min = MoneyField(max_digits=19, decimal_places=4, read_only=True)
purchase_price_max = MoneyField(max_digits=19, decimal_places=4, read_only=True)
purchase_price_avg = serializers.SerializerMethodField()
purchase_price_range = serializers.SerializerMethodField()
on_order = serializers.FloatField(read_only=True)
# Cached pricing fields
pricing_min = InvenTreeMoneySerializer(source='sub_part.pricing.overall_min', allow_null=True, read_only=True)
pricing_max = InvenTreeMoneySerializer(source='sub_part.pricing.overall_max', allow_null=True, read_only=True)
# Annotated fields for available stock
available_stock = serializers.FloatField(read_only=True)
available_substitute_stock = serializers.FloatField(read_only=True)
@ -604,7 +676,6 @@ class BomItemSerializer(InvenTreeModelSerializer):
"""
part_detail = kwargs.pop('part_detail', False)
sub_part_detail = kwargs.pop('sub_part_detail', False)
include_pricing = kwargs.pop('include_pricing', False)
super(BomItemSerializer, self).__init__(*args, **kwargs)
@ -614,14 +685,6 @@ class BomItemSerializer(InvenTreeModelSerializer):
if sub_part_detail is not True:
self.fields.pop('sub_part_detail')
if not include_pricing:
# Remove all pricing related fields
self.fields.pop('price_range')
self.fields.pop('purchase_price_min')
self.fields.pop('purchase_price_max')
self.fields.pop('purchase_price_avg')
self.fields.pop('purchase_price_range')
@staticmethod
def setup_eager_loading(queryset):
"""Prefetch against the provided queryset to speed up database access"""
@ -643,7 +706,6 @@ class BomItemSerializer(InvenTreeModelSerializer):
'substitutes__part__stock_items',
)
queryset = queryset.prefetch_related('sub_part__supplier_parts__pricebreaks')
return queryset
@staticmethod
@ -717,51 +779,6 @@ class BomItemSerializer(InvenTreeModelSerializer):
return queryset
def get_purchase_price_range(self, obj):
"""Return purchase price range."""
try:
purchase_price_min = obj.purchase_price_min
except AttributeError:
return None
try:
purchase_price_max = obj.purchase_price_max
except AttributeError:
return None
if purchase_price_min and not purchase_price_max:
# Get price range
purchase_price_range = str(purchase_price_max)
elif not purchase_price_min and purchase_price_max:
# Get price range
purchase_price_range = str(purchase_price_max)
elif purchase_price_min and purchase_price_max:
# Get price range
if purchase_price_min >= purchase_price_max:
# If min > max: use min only
purchase_price_range = str(purchase_price_min)
else:
purchase_price_range = str(purchase_price_min) + " - " + str(purchase_price_max)
else:
purchase_price_range = '-'
return purchase_price_range
def get_purchase_price_avg(self, obj):
"""Return purchase price average."""
try:
purchase_price_avg = obj.purchase_price_avg
except AttributeError:
return None
if purchase_price_avg:
# Get string representation of price average
purchase_price_avg = str(purchase_price_avg)
else:
purchase_price_avg = '-'
return purchase_price_avg
class Meta:
"""Metaclass defining serializer fields"""
model = BomItem
@ -775,16 +792,13 @@ class BomItemSerializer(InvenTreeModelSerializer):
'pk',
'part',
'part_detail',
'purchase_price_avg',
'purchase_price_max',
'purchase_price_min',
'purchase_price_range',
'pricing_min',
'pricing_max',
'quantity',
'reference',
'sub_part',
'sub_part_detail',
'substitutes',
'price_range',
'validated',
# Annotated fields describing available quantity

View File

@ -1,13 +1,17 @@
"""Background task definitions for the 'part' app"""
import logging
from datetime import datetime, timedelta
from django.utils.translation import gettext_lazy as _
import common.models
import common.notifications
import common.settings
import InvenTree.helpers
import InvenTree.tasks
import part.models
from InvenTree.tasks import ScheduledTask, scheduled_task
logger = logging.getLogger("inventree")
@ -53,3 +57,70 @@ def notify_low_stock_if_required(part: part.models.Part):
notify_low_stock,
p
)
def update_part_pricing(pricing: part.models.PartPricing, counter: int = 0):
"""Update cached pricing data for the specified PartPricing instance
Arguments:
pricing: The target PartPricing instance to be updated
counter: How many times this function has been called in sequence
"""
logger.info(f"Updating part pricing for {pricing.part}")
pricing.update_pricing(counter=counter)
@scheduled_task(ScheduledTask.DAILY)
def check_missing_pricing(limit=250):
"""Check for parts with missing or outdated pricing information:
- Pricing information does not exist
- Pricing information is "old"
- Pricing information is in the wrong currency
Arguments:
limit: Maximum number of parts to process at once
"""
# Find parts for which pricing information has never been updated
results = part.models.PartPricing.objects.filter(updated=None)[:limit]
if results.count() > 0:
logger.info(f"Found {results.count()} parts with empty pricing")
for pp in results:
pp.schedule_for_update()
# Find any parts which have 'old' pricing information
days = int(common.models.InvenTreeSetting.get_setting('PRICING_UPDATE_DAYS', 30))
stale_date = datetime.now().date() - timedelta(days=days)
results = part.models.PartPricing.objects.filter(updated__lte=stale_date)[:limit]
if results.count() > 0:
logger.info(f"Found {results.count()} stale pricing entries")
for pp in results:
pp.schedule_for_update()
# Find any pricing data which is in the wrong currency
currency = common.settings.currency_code_default()
results = part.models.PartPricing.objects.exclude(currency=currency)
if results.count() > 0:
logger.info(f"Found {results.count()} pricing entries in the wrong currency")
for pp in results:
pp.schedule_for_update()
# Find any parts which do not have pricing information
results = part.models.Part.objects.filter(pricing_data=None)[:limit]
if results.count() > 0:
logger.info(f"Found {results.count()} parts without pricing")
for p in results:
pricing = p.pricing
pricing.schedule_for_update()

View File

@ -131,11 +131,9 @@
</div>
</div>
{% if part.purchaseable or part.salable %}
<div class='panel panel-hidden' id='panel-pricing'>
{% include "part/prices.html" %}
</div>
{% endif %}
<div class='panel panel-hidden' id='panel-part-notes'>
<div class='panel-heading'>
@ -878,162 +876,7 @@
});
onPanelLoad('pricing', function() {
{% default_currency as currency %}
// Load the BOM table data in the pricing view
{% if part.has_bom and roles.sales_order.view %}
loadBomTable($("#bom-pricing-table"), {
editable: false,
bom_url: "{% url 'api-bom-list' %}",
part_url: "{% url 'api-part-list' %}",
parent_id: {{ part.id }} ,
sub_part_detail: true,
});
{% endif %}
// history graphs
{% if price_history %}
var purchasepricedata = {
labels: [
{% for line in price_history %}'{% render_date line.date %}',{% endfor %}
],
datasets: [{
label: '{% blocktrans %}Purchase Unit Price - {{currency}}{% endblocktrans %}',
backgroundColor: 'rgba(255, 99, 132, 0.2)',
borderColor: 'rgb(255, 99, 132)',
yAxisID: 'y',
data: [
{% for line in price_history %}{{ line.price|stringformat:".2f" }},{% endfor %}
],
borderWidth: 1,
type: 'line'
},
{% if 'price_diff' in price_history.0 %}
{
label: '{% blocktrans %}Unit Price-Cost Difference - {{ currency }}{% endblocktrans %}',
backgroundColor: 'rgba(68, 157, 68, 0.2)',
borderColor: 'rgb(68, 157, 68)',
yAxisID: 'y2',
data: [
{% for line in price_history %}{{ line.price_diff|stringformat:".2f" }},{% endfor %}
],
borderWidth: 1,
type: 'line',
hidden: true,
},
{
label: '{% blocktrans %}Supplier Unit Cost - {{currency}}{% endblocktrans %}',
backgroundColor: 'rgba(70, 127, 155, 0.2)',
borderColor: 'rgb(70, 127, 155)',
yAxisID: 'y',
data: [
{% for line in price_history %}{{ line.price_part|stringformat:".2f" }},{% endfor %}
],
borderWidth: 1,
type: 'line',
hidden: true,
},
{% endif %}
{
label: '{% trans "Quantity" %}',
backgroundColor: 'rgba(255, 206, 86, 0.2)',
borderColor: 'rgb(255, 206, 86)',
yAxisID: 'y1',
data: [
{% for line in price_history %}{{ line.qty|stringformat:"f" }},{% endfor %}
],
borderWidth: 1
}]
}
var StockPriceChart = loadStockPricingChart($('#StockPriceChart'), purchasepricedata)
{% endif %}
{% if bom_parts %}
var bom_colors = randomColor({hue: 'green', count: {{ bom_parts|length }} })
var bomdata = {
labels: [{% for line in bom_parts %}'{{ line.name|escapejs }}',{% endfor %}],
datasets: [
{
label: 'Price',
data: [{% for line in bom_parts %}{{ line.min_price }},{% endfor %}],
backgroundColor: bom_colors,
},
{% if bom_pie_max %}
{
label: 'Max Price',
data: [{% for line in bom_parts %}{{ line.max_price }},{% endfor %}],
backgroundColor: bom_colors,
},
{% endif %}
]
};
var BomChart = loadBomChart(document.getElementById('BomChart'), bomdata)
{% endif %}
// Internal pricebreaks
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% if show_internal_price and roles.sales_order.view %}
initPriceBreakSet(
$('#internal-price-break-table'),
{
part_id: {{part.id}},
pb_human_name: 'internal price break',
pb_url_slug: 'internal-price',
pb_url: '{% url 'api-part-internal-price-list' %}',
pb_new_btn: $('#new-internal-price-break'),
pb_new_url: '{% url 'api-part-internal-price-list' %}',
linkedGraph: $('#InternalPriceBreakChart'),
},
);
{% endif %}
// Sales pricebreaks
{% if part.salable and roles.sales_order.view %}
initPriceBreakSet(
$('#price-break-table'),
{
part_id: {{part.id}},
pb_human_name: 'sale price break',
pb_url_slug: 'sale-price',
pb_url: "{% url 'api-part-sale-price-list' %}",
pb_new_btn: $('#new-price-break'),
pb_new_url: '{% url 'api-part-sale-price-list' %}',
linkedGraph: $('#SalePriceBreakChart'),
},
);
{% endif %}
// Sale price history
{% if sale_history %}
var salepricedata = {
labels: [
{% for line in sale_history %}'{% render_date line.date %}',{% endfor %}
],
datasets: [{
label: '{% blocktrans %}Unit Price - {{currency}}{% endblocktrans %}',
backgroundColor: 'rgba(255, 99, 132, 0.2)',
borderColor: 'rgb(255, 99, 132)',
yAxisID: 'y',
data: [
{% for line in sale_history %}{{ line.price|stringformat:".2f" }},{% endfor %}
],
borderWidth: 1,
},
{
label: '{% trans "Quantity" %}',
backgroundColor: 'rgba(255, 206, 86, 0.2)',
borderColor: 'rgb(255, 206, 86)',
yAxisID: 'y1',
data: [
{% for line in sale_history %}{{ line.qty|stringformat:"f" }},{% endfor %}
],
borderWidth: 1,
type: 'bar',
}]
}
var SalePriceChart = loadSellPricingChart($('#SalePriceChart'), salepricedata)
{% endif %}
{% include "part/pricing_javascript.html" %}
});
enableSidebar('part');

View File

@ -323,6 +323,21 @@
{% endif %}
</td>
</tr>
{% with part.pricing as pricing %}
{% if pricing.is_valid %}
<tr>
<td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "Price Range" %}</td>
<td>
{% if pricing.overall_min == pricing.overall_max %}
{% render_currency pricing.overall_max %}
{% else %}
{% render_currency pricing.overall_min %} - {% render_currency pricing.overall_max %}
{% endif %}
</td>
</tr>
{% endif %}
{% endwith %}
{% with part.get_latest_serial_number as sn %}
{% if part.trackable and sn %}
<tr>

View File

@ -76,14 +76,6 @@
{% endif %}
{% endif %}
{% if not part.has_complete_bom_pricing %}
<tr>
<td colspan='3'>
<span class='warning-msg'><em>{% trans 'Note: BOM pricing is incomplete for this part' %}</em></span>
</td>
</tr>
{% endif %}
{% if min_total_bom_price or min_total_bom_purchase_price %}
{% else %}
<tr>

View File

@ -27,10 +27,8 @@
{% trans "Used In" as text %}
{% include "sidebar_item.html" with label="used-in" text=text icon="fa-layer-group" %}
{% endif %}
{% if part.purchaseable or part.salable %}
{% trans "Pricing" as text %}
{% include "sidebar_item.html" with label="pricing" text=text icon="fa-dollar-sign" %}
{% endif %}
{% if part.purchaseable and roles.purchase_order.view %}
{% trans "Suppliers" as text %}
{% include "sidebar_item.html" with label="suppliers" text=text icon="fa-building" %}

View File

@ -5,252 +5,299 @@
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% if show_price_history %}
<a class="anchor" id="overview"></a>
<div class='panel-heading'>
<h4>{% trans "Pricing Information" %}</h4>
<div class='d-flex flex-wrap'>
<h4>{% trans "Pricing Overview" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
<button type='button' class='btn btn-success' id='part-pricing-refresh' title='{% trans "Refresh Part Pricing" %}'>
<span class='fas fa-redo-alt'></span> {% trans "Refresh" %}
</button>
</div>
</div>
</div>
{% default_currency as currency %}
<div class='panel-content'>
<div class="row">
<a class="anchor" id="overview"></a>
<div class="col col-md-6">
<h4>{% trans "Pricing ranges" %}</h4>
{% with part.pricing as pricing %}
{% if pricing.is_valid %}
<!-- Part pricing table -->
<div class='alert alert-info alert-block'>
{% trans "Last Updated" %}: {% render_date pricing.updated %}
</div>
<div class='row full-height'>
<div class='col col-md-6'>
<table class='table table-striped table-condensed'>
{% if part.supplier_count > 0 %}
{% if min_total_buy_price %}
<col width='25'>
<thead>
<tr>
<td><strong>{% trans 'Supplier Pricing' %}</strong>
<a href="#supplier-cost" title='{% trans "Show supplier cost" %}'><span class="fas fa-search-dollar"></span></a>
<a href="#purchase-price" title='{% trans "Show purchase price" %}'><span class="fas fa-chart-bar"></span></a>
</td>
<td>{% trans 'Unit Cost' %}</td>
<td>Min: {% include "price.html" with price=min_unit_buy_price %}</td>
<td>Max: {% include "price.html" with price=max_unit_buy_price %}</td>
<th></th>
<th>{% trans "Price Category" %}</th>
<th>{% trans "Minimum" %}</th>
<th>{% trans "Maximum" %}</th>
</tr>
{% if quantity > 1 %}
</thead>
<tbody>
<tr>
<td></td>
<td>{% trans 'Total Cost' %}</td>
<td>Min: {% include "price.html" with price=min_total_buy_price %}</td>
<td>Max: {% include "price.html" with price=max_total_buy_price %}</td>
<td>
{% if show_internal_price and roles.sales_order.view %}
<a href='#internal-cost'>
<span class='fas fa-dollar-sign'></span>
</a>
{% endif %}
</td>
<th>
{% trans "Internal Pricing" %}
</th>
<td>{% include "price_data.html" with price=pricing.internal_cost_min %}</td>
<td>{% include "price_data.html" with price=pricing.internal_cost_max %}</td>
</tr>
{% if part.purchaseable %}
<tr>
<td>
{% if roles.purchase_order.view %}
<a href='#purchase-price-history'>
<span class='fas fa-chart-line'></span>
</a>
{% endif %}
</td>
<th>
{% trans "Purchase History" %}
</th>
<td>{% include "price_data.html" with price=pricing.purchase_cost_min %}</td>
<td>{% include "price_data.html" with price=pricing.purchase_cost_max %}</td>
</tr>
<tr>
<td>
{% if roles.purchase_order.view %}
<a href='#supplier-prices'>
<span class='fas fa-building'></span>
</a>
{% endif %}
</td>
<th>
{% trans "Supplier Pricing" %}
</th>
<td>{% include "price_data.html" with price=pricing.supplier_price_min %}</td>
<td>{% include "price_data.html" with price=pricing.supplier_price_max %}</td>
</tr>
{% endif %}
{% else %}
{% if part.assembly %}
<tr>
<td colspan='4'>
<span class='warning-msg'><em>{% trans 'No supplier pricing available' %}</em></span>
<td>
{% if part.has_bom %}
<a href='#bom-cost'>
<span class='fas fa-tools'></span>
</a>
{% endif %}
</td>
</tr>
{% endif %}
{% endif %}
{% if part.assembly and part.bom_count > 0 %}
{% if min_total_bom_price %}
<tr>
<td><strong>{% trans 'BOM Pricing' %}</strong>
<a href="#bom-cost" title='{% trans "Show BOM cost" %}'><span class="fas fa-search-dollar"></span></a>
</td>
<td>{% trans 'Unit Cost' %}</td>
<td>Min: {% include "price.html" with price=min_unit_bom_price %}</td>
<td>Max: {% include "price.html" with price=max_unit_bom_price %}</td>
</tr>
{% if quantity > 1 %}
<tr>
<td></td>
<td>{% trans 'Total Cost' %}</td>
<td>Min: {% include "price.html" with price=min_total_bom_price %}</td>
<td>Max: {% include "price.html" with price=max_total_bom_price %}</td>
</tr>
{% endif %}
{% endif %}
{% if min_total_bom_purchase_price %}
<tr>
<td></td>
<td>{% trans 'Unit Purchase Price' %}</td>
<td>Min: {% include "price.html" with price=min_unit_bom_purchase_price %}</td>
<td>Max: {% include "price.html" with price=max_unit_bom_purchase_price %}</td>
</tr>
{% if quantity > 1 %}
<tr>
<td></td>
<td>{% trans 'Total Purchase Price' %}</td>
<td>Min: {% include "price.html" with price=min_total_bom_purchase_price %}</td>
<td>Max: {% include "price.html" with price=max_total_bom_purchase_price %}</td>
<th>
{% trans "BOM Pricing" %}
</th>
<td>{% include "price_data.html" with price=pricing.bom_cost_min %}</td>
<td>{% include "price_data.html" with price=pricing.bom_cost_max %}</td>
</tr>
{% endif %}
{% endif %}
{% if not part.has_complete_bom_pricing %}
{% if part.is_template %}
<tr>
<td colspan='4'>
<span class='warning-msg'><em>{% trans 'Note: BOM pricing is incomplete for this part' %}</em></span>
</td>
<td><a href='#variant-cost'><span class='fas fa-shapes'></span></a></td>
<th>{% trans "Variant Pricing" %}</th>
<td>{% include "price_data.html" with price=pricing.variant_cost_min %}</td>
<td>{% include "price_data.html" with price=pricing.variant_cost_max %}</td>
</tr>
{% endif %}
{% if min_total_bom_price or min_total_bom_purchase_price %}
{% else %}
{% endif %}
<tr>
<td colspan='4'>
<span class='warning-msg'><em>{% trans 'No BOM pricing available' %}</em></span>
</td>
<td></td>
<th>
{% trans "Overall Pricing" %}
</th>
<th>{% include "price_data.html" with price=pricing.overall_min %}</th>
<th>{% include "price_data.html" with price=pricing.overall_max %}</th>
</tr>
{% endif %}
{% endif %}
{% if show_internal_price and roles.sales_order.view %}
{% if total_internal_part_price %}
<tr>
<td><strong>{% trans 'Internal Price' %}</strong></td>
<td>{% trans 'Unit Cost' %}</td>
<td colspan='2'>{% include "price.html" with price=unit_internal_part_price %}</td>
</tr>
<tr>
<td></td>
<td>{% trans 'Total Cost' %}</td>
<td colspan='2'>{% include "price.html" with price=total_internal_part_price %}</td>
</tr>
{% endif %}
{% endif %}
{% if total_part_price %}
<tr>
<td><strong>{% trans 'Sale Price' %}</strong>
<a href="#sale-cost" title='{% trans "Show sale cost" %}'><span class="fas fa-search-dollar"></span></a>
<a href="#sale-price" title='{% trans "Show sale price" %}'><span class="fas fa-chart-bar"></span></a>
</td>
<td>{% trans 'Unit Cost' %}</td>
<td colspan='2'>{% include "price.html" with price=unit_part_price %}</td>
</tr>
<tr>
<td></td>
<td>{% trans 'Total Cost' %}</td>
<td colspan='2'>{% include "price.html" with price=total_part_price %}</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<div class='col col-md-6'>
{% if part.salable and roles.sales_order.view %}
<table class='table table-striped table-condensed'>
<col width='25'>
<thead>
<tr>
<th></th>
<th>{% trans "Price Category" %}</th>
<th>{% trans "Minimum" %}</th>
<th>{% trans "Maximum" %}</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<a href='#sale-cost'>
<span class='fas fa-dollar-sign'></span>
</a>
</td>
<th>
{% trans "Sale Price" %}
</th>
<td>
{% include "price_data.html" with price=pricing.sale_price_min %}
</td>
<td>
{% include "price_data.html" with price=pricing.sale_price_max %}
</td>
</tr>
<tr>
<td>
<a href='#sale-price-history'>
<span class='fas fa-chart-line'></span>
</a>
</td>
<th>
{% trans "Sale History" %}
</th>
<td>
{% include "price_data.html" with price=pricing.sale_history_min %}
</td>
<td>
{% include "price_data.html" with price=pricing.sale_history_max %}
</td>
</tr>
</tbody>
</table>
{% if min_unit_buy_price or min_unit_bom_price or min_unit_bom_purchase_price %}
{% else %}
<div class='alert alert-danger alert-block'>
{% trans 'No pricing information is available for this part.' %}
</div>
<div class='alert alert-block alert-info'>
{% trans "Sale price data is not available for this part" %}
</div>
{% endif %}
</div>
<div class="col col-md-6">
<h4>{% trans "Calculation parameters" %}</h4>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<input type="submit" value="{% trans 'Calculate' %}" class="btn btn-primary btn-block">
</form>
</div>
</div>
</div>
{% endif %}
{% if part.purchaseable and roles.purchase_order.view %}
<a class="anchor" id="supplier-cost"></a>
<div class='panel-heading'>
<h4>{% trans "Supplier Cost" %}
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
</h4>
</div>
<div class='panel-content'>
<div class="row">
<div class="col col-md-6">
<h4>{% trans "Suppliers" %}</h4>
<table class="table table-striped table-condensed" id='supplier-table' data-toolbar='#button-toolbar'></table>
</div>
<div class="col col-md-6">
<h4>{% trans "Manufacturers" %}</h4>
<table class="table table-striped table-condensed" id='manufacturer-table' data-toolbar='#button-toolbar'></table>
</div>
{% else %}
<div class='alert alert-warning alert-block'>
{% trans "Price range data is not available for this part." %}
</div>
{% endif %}
{% endwith %}
</div>
{% if show_price_history %}
<a class="anchor" id="purchase-price"></a>
<div class='panel-heading'>
<h4>{% trans "Purchase Price" %}
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
</h4>
</div>
<div class='panel-content'>
<h4>{% trans 'Stock Pricing' %}
<em class="fas fa-info-circle" title="Shows the purchase prices of stock for this part.&#10;The Supplier Unit Cost is the current purchase price for that supplier part."></em>
</h4>
{% if price_history|length > 0 %}
<div style="max-width: 99%; min-height: 300px">
<canvas id="StockPriceChart"></canvas>
</div>
{% else %}
<div class='alert alert-danger alert-block'>
{% trans 'No stock pricing history is available for this part.' %}
</div>
{% endif %}
</div>
{% endif %}
{% endif %}
{% if show_internal_price and roles.sales_order.view %}
<a class="anchor" id="internal-cost"></a>
<div class='panel-heading'>
<h4>{% trans "Internal Cost" %}
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
</h4>
<div class='d-flex flex-wrap'>
<h4>{% trans "Internal Pricing" %}
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
<button class='btn btn-success' id='new-internal-price-break' type='button'>
<span class='fas fa-plus-circle'></span> {% trans "Add Internal Price Break" %}
</button>
</div>
</div>
</div>
<div class='panel-content'>
<div class="row full-height">
<div class="col col-md-8">
<div class="col col-md-6">
<div style="max-width: 99%; height: 100%;">
<canvas id="InternalPriceBreakChart"></canvas>
</div>
</div>
<div class="col col-md-4">
<div id='internal-price-break-toolbar' class='btn-group'>
<button class='btn btn-success' id='new-internal-price-break' type='button'>
<span class='fas fa-plus-circle'></span> {% trans "Add Internal Price Break" %}
</button>
</div>
<div class="col col-md-6">
<table class='table table-striped table-condensed' id='internal-price-break-table' data-toolbar='#internal-price-break-toolbar'
data-sort-name="quantity" data-sort-order="asc">
<table class='table table-striped table-condensed' id='internal-price-break-table'>
</table>
</div>
</div>
</div>
{% endif %}
{% if part.has_bom and roles.sales_order.view %}
<a class="anchor" id="bom-cost"></a>
{% if part.purchaseable and roles.purchase_order.view %}
<a class="anchor" id="purchase-price-history"></a>
<div class='panel-heading'>
<h4>{% trans "BOM Cost" %}
<h4>
{% trans "Purchase History" %}
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
</h4>
</div>
<div class='panel-content'>
<div class="row full-height">
<div class="col col-md-6">
<div style="max-width: 99%; height: 100%;">
<canvas id="part-purchase-history-chart"></canvas>
</div>
</div>
<div class="col col-md-6">
<table class='table table-striped table-condensed' id='part-purchase-history-table'>
</table>
</div>
</div>
</div>
<a class="anchor" id="supplier-prices"></a>
<div class='panel-heading'>
<h4>
{% trans "Supplier Pricing" %}
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
</h4>
</div>
<div class='panel-content'>
<div class="row">
<div class='row full-height'>
<div class="col col-md-6">
<table class='table table-bom table-condensed' data-toolbar="#button-toolbar" id='bom-pricing-table'></table>
</div>
{% if part.bom_count > 0 %}
<div class="col col-md-6">
<h4>{% trans 'BOM Pricing' %}</h4>
<div style="max-width: 99%;">
<canvas id="BomChart"></canvas>
<div style="max-width: 99%; height: 100%;">
<canvas id="part-supplier-pricing-chart"></canvas>
</div>
</div>
{% endif %}
<div class="col col-md-6">
<table class='table table-striped table-condensed' id='part-supplier-pricing-table'>
</table>
</div>
</div>
</div>
{% endif %}
{% if part.assembly and part.has_bom %}
<a class="anchor" id="bom-cost"></a>
<div class='panel-heading'>
<h4>{% trans "BOM Pricing" %}
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
</h4>
</div>
<div class='panel-content'>
<div class='row full-height'>
<div class="col col-md-6">
<div style="max-width: 99%; height: 100%;">
<canvas id="bom-pricing-chart"></canvas>
</div>
</div>
<div class="col col-md-6">
<table class='table table-striped table-condensed' id='bom-pricing-table'>
</table>
</div>
</div>
</div>
{% endif %}
{% if part.is_template %}
<a class='anchor' id='variant-cost'></a>
<div class='panel-heading'>
<h4>
{% trans "Variant Pricing" %}
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
</h4>
</div>
<div class='panel-content'>
<div class="row full-height">
<div class="col col-md-6">
<div style="max-width: 99%; height: 100%;">
<canvas id="variant-pricing-chart"></canvas>
</div>
</div>
<div class="col col-md-6">
<table class='table table-striped table-condensed' id='variant-pricing-table'>
</table>
</div>
</div>
</div>
{% endif %}
@ -258,50 +305,52 @@
{% if part.salable and roles.sales_order.view %}
<a class="anchor" id="sale-cost"></a>
<div class='panel-heading'>
<h4>{% trans "Sale Cost" %}
<div class='d-flex flex-wrap'>
<h4>{% trans "Sale Pricing" %}
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
<button class='btn btn-success' id='new-price-break' type='button'>
<span class='fas fa-plus-circle'></span> {% trans "Add Sell Price Break" %}
</button>
</div>
</div>
</div>
<div class='panel-content'>
<div class="row full-height">
<div class="col col-md-6">
<div style="max-width: 99%; height: 100%;">
<canvas id="SalePriceBreakChart"></canvas>
</div>
</div>
<div class="col col-md-6">
<table class='table table-striped table-condensed' id='price-break-table'>
</table>
</div>
</div>
</div>
<a class="anchor" id="sale-price-history"></a>
<div class='panel-heading'>
<div class='d-flex flex-wrap'></div>
<h4>{% trans "Sale History" %}
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
</h4>
</div>
<div class='panel-content'>
<div class="row full-height">
<div class="col col-md-8">
<div class="col col-md-6">
<div style="max-width: 99%; height: 100%;">
<canvas id="SalePriceBreakChart"></canvas>
<canvas id="part-sales-history-chart"></canvas>
</div>
</div>
<div class="col col-md-4">
<div id='price-break-toolbar' class='btn-group'>
<button class='btn btn-success' id='new-price-break' type='button'>
<span class='fas fa-plus-circle'></span> {% trans "Add Price Break" %}
</button>
</div>
<table class='table table-striped table-condensed' id='price-break-table' data-toolbar='#price-break-toolbar'
data-sort-name="quantity" data-sort-order="asc">
<div class="col col-md-6">
<table class='table table-striped table-condensed' id='part-sales-history-table'>
</table>
</div>
</div>
</div>
{% if show_price_history %}
<a class="anchor" id="sale-price"></a>
<div class='panel-heading'>
<h4>{% trans "Sale Price" %}
<a href="#overview" title='{% trans "Jump to overview" %}'><span class="fas fa-level-up-alt"></span></a>
</h4>
</div>
<div class='panel-content'>
{% if sale_history|length > 0 %}
<div style="max-width: 99%; min-height: 300px">
<canvas id="SalePriceChart"></canvas>
</div>
{% else %}
<div class='alert alert-danger alert-block'>
{% trans 'No sale pice history available for this part.' %}
</div>
{% endif %}
</div>
{% endif %}
{% endif %}

View File

@ -0,0 +1,80 @@
{% load inventree_extras %}
{% load i18n %}
{% settings_value "PART_INTERNAL_PRICE" as show_internal_price %}
{% default_currency as currency %}
// Callback for "part pricing" button
$('#part-pricing-refresh').click(function() {
inventreePut(
'{% url "api-part-pricing" part.pk %}',
{
update: true,
},
{
success: function(response) {
location.reload();
}
}
);
});
// Internal Pricebreaks
{% if show_internal_price and roles.sales_order.view %}
initPriceBreakSet($('#internal-price-break-table'), {
part_id: {{part.id}},
pb_human_name: 'internal price break',
pb_url_slug: 'internal-price',
pb_url: '{% url 'api-part-internal-price-list' %}',
pb_new_btn: $('#new-internal-price-break'),
pb_new_url: '{% url 'api-part-internal-price-list' %}',
linkedGraph: $('#InternalPriceBreakChart'),
});
{% endif %}
// Purchase price history
loadPurchasePriceHistoryTable({
part: {{ part.pk }},
});
{% if part.purchaseable and roles.purchase_order.view %}
// Supplier pricing information
loadPartSupplierPricingTable({
part: {{ part.pk }},
});
{% endif %}
{% if part.assembly and part.has_bom %}
// BOM Pricing Data
loadBomPricingChart({
part: {{ part.pk }}
});
{% endif %}
{% if part.is_template %}
// Variant pricing data
loadVariantPricingChart({
part: {{ part.pk }}
});
{% endif %}
{% if part.salable and roles.sales_order.view %}
// Sales pricebreaks
initPriceBreakSet(
$('#price-break-table'),
{
part_id: {{part.id}},
pb_human_name: 'sale price break',
pb_url_slug: 'sale-price',
pb_url: "{% url 'api-part-sale-price-list' %}",
pb_new_btn: $('#new-price-break'),
pb_new_url: '{% url 'api-part-sale-price-list' %}',
linkedGraph: $('#SalePriceBreakChart'),
},
);
loadSalesPriceHistoryTable({
part: {{ part.pk }}
});
{% endif %}

View File

@ -4,6 +4,7 @@ import logging
import os
import sys
from datetime import date, datetime
from decimal import Decimal
from django import template
from django.conf import settings as djangosettings
@ -13,6 +14,8 @@ from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
import moneyed.localization
import InvenTree.helpers
from common.models import ColorTheme, InvenTreeSetting, InvenTreeUserSetting
from common.settings import currency_code_default
@ -37,6 +40,12 @@ def define(value, *args, **kwargs):
return value
@register.simple_tag()
def decimal(x, *args, **kwargs):
"""Simplified rendering of a decimal number."""
return InvenTree.helpers.decimal2string(x)
@register.simple_tag(takes_context=True)
def render_date(context, date_object):
"""Renders a date according to the preference of the provided user.
@ -94,10 +103,34 @@ def render_date(context, date_object):
return date_object
@register.simple_tag()
def decimal(x, *args, **kwargs):
"""Simplified rendering of a decimal number."""
return InvenTree.helpers.decimal2string(x)
@register.simple_tag
def render_currency(money, decimal_places=None, include_symbol=True):
"""Render a currency / Money object"""
if money is None or money.amount is None:
return '-'
if decimal_places is None:
decimal_places = InvenTreeSetting.get_setting('PRICING_DECIMAL_PLACES', 6)
value = Decimal(str(money.amount)).normalize()
value = str(value)
if '.' in value:
decimals = len(value.split('.')[-1])
decimals = max(decimals, 2)
decimals = min(decimals, decimal_places)
decimal_places = decimals
else:
decimal_places = 2
return moneyed.localization.format_money(
money,
decimal_places=decimal_places,
include_symbol=include_symbol,
)
@register.simple_tag()

View File

@ -1182,17 +1182,17 @@ class PartAPITest(InvenTreeAPITestCase):
url = reverse('api-part-list')
required_cols = [
'id',
'name',
'description',
'in_stock',
'category_name',
'keywords',
'is_template',
'virtual',
'trackable',
'active',
'notes',
'Part ID',
'Part Name',
'Part Description',
'In Stock',
'Category Name',
'Keywords',
'Template',
'Virtual',
'Trackable',
'Active',
'Notes',
'creation_date',
]
@ -1217,16 +1217,16 @@ class PartAPITest(InvenTreeAPITestCase):
)
for row in data:
part = Part.objects.get(pk=row['id'])
part = Part.objects.get(pk=row['Part ID'])
if part.IPN:
self.assertEqual(part.IPN, row['IPN'])
self.assertEqual(part.name, row['name'])
self.assertEqual(part.description, row['description'])
self.assertEqual(part.name, row['Part Name'])
self.assertEqual(part.description, row['Part Description'])
if part.category:
self.assertEqual(part.category.name, row['category_name'])
self.assertEqual(part.category.name, row['Category Name'])
class PartDetailTests(InvenTreeAPITestCase):
@ -1561,6 +1561,56 @@ class PartDetailTests(InvenTreeAPITestCase):
self.assertIn('Ensure this field has no more than 50000 characters', str(response.data['notes']))
class PartPricingDetailTests(InvenTreeAPITestCase):
"""Tests for the part pricing API endpoint"""
fixtures = [
'category',
'part',
'location',
]
roles = [
'part.change',
]
def url(self, pk):
"""Construct a pricing URL"""
return reverse('api-part-pricing', kwargs={'pk': pk})
def test_pricing_detail(self):
"""Test an empty pricing detail"""
response = self.get(
self.url(1),
expected_code=200
)
# Check for expected fields
expected_fields = [
'currency',
'updated',
'bom_cost_min',
'bom_cost_max',
'purchase_cost_min',
'purchase_cost_max',
'internal_cost_min',
'internal_cost_max',
'supplier_price_min',
'supplier_price_max',
'overall_min',
'overall_max',
]
for field in expected_fields:
self.assertIn(field, response.data)
# Empty fields (no pricing by default)
for field in expected_fields[2:]:
self.assertIsNone(response.data[field])
class PartAPIAggregationTest(InvenTreeAPITestCase):
"""Tests to ensure that the various aggregation annotations are working correctly..."""

View File

@ -58,21 +58,20 @@ class BomExportTest(InvenTreeTestCase):
break
expected = [
'part_id',
'part_ipn',
'part_name',
'quantity',
'Part ID',
'Part IPN',
'Quantity',
'Reference',
'Note',
'optional',
'overage',
'reference',
'note',
'inherited',
'allow_variants',
]
# Ensure all the expected headers are in the provided file
for header in expected:
self.assertTrue(header in headers)
self.assertIn(header, headers)
def test_export_csv(self):
"""Test BOM download in CSV format."""
@ -106,22 +105,22 @@ class BomExportTest(InvenTreeTestCase):
break
expected = [
'level',
'bom_id',
'parent_part_id',
'parent_part_ipn',
'parent_part_name',
'part_id',
'part_ipn',
'part_name',
'part_description',
'sub_assembly',
'quantity',
'BOM Level',
'BOM Item ID',
'Parent ID',
'Parent IPN',
'Parent Name',
'Part ID',
'Part IPN',
'Part Name',
'Description',
'Assembly',
'Quantity',
'optional',
'consumable',
'overage',
'reference',
'note',
'Reference',
'Note',
'inherited',
'allow_variants',
'Default Location',
@ -131,10 +130,10 @@ class BomExportTest(InvenTreeTestCase):
]
for header in expected:
self.assertTrue(header in headers)
self.assertIn(header, headers)
for header in headers:
self.assertTrue(header in expected)
self.assertIn(header, expected)
def test_export_xls(self):
"""Test BOM download in XLS format."""

View File

@ -148,7 +148,7 @@ class CategoryTest(TestCase):
def test_parameters(self):
"""Test that the Category parameters are correctly fetched."""
# Check number of SQL queries to iterate other parameters
with self.assertNumQueries(7):
with self.assertNumQueries(8):
# Prefetch: 3 queries (parts, parameters and parameters_template)
fasteners = self.fasteners.prefetch_parts_parameters()
# Iterate through all parts and parameters

View File

@ -0,0 +1,330 @@
"""Unit tests for Part pricing calculations"""
from django.core.exceptions import ObjectDoesNotExist
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from djmoney.money import Money
import common.models
import common.settings
import company.models
import order.models
import part.models
from InvenTree.helpers import InvenTreeTestCase
from InvenTree.status_codes import PurchaseOrderStatus
class PartPricingTests(InvenTreeTestCase):
"""Unit tests for part pricing calculations"""
def generate_exchange_rates(self):
"""Generate some exchange rates to work with"""
rates = {
'AUD': 1.5,
'CAD': 1.7,
'GBP': 0.9,
'USD': 1.0,
}
# Create a dummy backend
ExchangeBackend.objects.create(
name='InvenTreeExchange',
base_currency='USD',
)
backend = ExchangeBackend.objects.get(name='InvenTreeExchange')
for currency, rate in rates.items():
Rate.objects.create(
currency=currency,
value=rate,
backend=backend,
)
def setUp(self):
"""Setup routines"""
self.generate_exchange_rates()
# Create a new part for performing pricing calculations
self.part = part.models.Part.objects.create(
name='PP',
description='A part with pricing',
assembly=True
)
return super().setUp()
def create_price_breaks(self):
"""Create some price breaks for the part, in various currencies"""
# First supplier part (CAD)
self.supplier_1 = company.models.Company.objects.create(
name='Supplier 1',
is_supplier=True
)
self.sp_1 = company.models.SupplierPart.objects.create(
supplier=self.supplier_1,
part=self.part,
SKU='SUP_1',
)
company.models.SupplierPriceBreak.objects.create(
part=self.sp_1,
quantity=1,
price=10.4,
price_currency='CAD',
)
# Second supplier part (AUD)
self.supplier_2 = company.models.Company.objects.create(
name='Supplier 2',
is_supplier=True
)
self.sp_2 = company.models.SupplierPart.objects.create(
supplier=self.supplier_2,
part=self.part,
SKU='SUP_2',
pack_size=2.5,
)
self.sp_3 = company.models.SupplierPart.objects.create(
supplier=self.supplier_2,
part=self.part,
SKU='SUP_3',
pack_size=10
)
company.models.SupplierPriceBreak.objects.create(
part=self.sp_2,
quantity=5,
price=7.555,
price_currency='AUD',
)
# Third supplier part (GBP)
company.models.SupplierPriceBreak.objects.create(
part=self.sp_2,
quantity=10,
price=4.55,
price_currency='GBP',
)
def test_pricing_data(self):
"""Test link between Part and PartPricing model"""
# Initially there is no associated Pricing data
with self.assertRaises(ObjectDoesNotExist):
pricing = self.part.pricing_data
# Accessing in this manner should create the associated PartPricing instance
pricing = self.part.pricing
self.assertEqual(pricing.part, self.part)
# Default values should be null
self.assertIsNone(pricing.bom_cost_min)
self.assertIsNone(pricing.bom_cost_max)
self.assertIsNone(pricing.internal_cost_min)
self.assertIsNone(pricing.internal_cost_max)
self.assertIsNone(pricing.overall_min)
self.assertIsNone(pricing.overall_max)
def test_invalid_rate(self):
"""Ensure that conversion behaves properly with missing rates"""
...
def test_simple(self):
"""Tests for hard-coded values"""
pricing = self.part.pricing
# Add internal pricing
pricing.internal_cost_min = Money(1, 'USD')
pricing.internal_cost_max = Money(4, 'USD')
pricing.save()
self.assertEqual(pricing.overall_min, Money('1', 'USD'))
self.assertEqual(pricing.overall_max, Money('4', 'USD'))
# Add supplier pricing
pricing.supplier_price_min = Money(10, 'AUD')
pricing.supplier_price_max = Money(15, 'CAD')
pricing.save()
# Minimum pricing should not have changed
self.assertEqual(pricing.overall_min, Money('1', 'USD'))
# Maximum price has changed, and was specified in a different currency
self.assertEqual(pricing.overall_max, Money('8.823529', 'USD'))
# Add BOM cost
pricing.bom_cost_min = Money(0.1, 'GBP')
pricing.bom_cost_max = Money(25, 'USD')
pricing.save()
self.assertEqual(pricing.overall_min, Money('0.111111', 'USD'))
self.assertEqual(pricing.overall_max, Money('25', 'USD'))
def test_supplier_part_pricing(self):
"""Test for supplier part pricing"""
pricing = self.part.pricing
# Initially, no information (not yet calculated)
self.assertIsNone(pricing.supplier_price_min)
self.assertIsNone(pricing.supplier_price_max)
self.assertIsNone(pricing.overall_min)
self.assertIsNone(pricing.overall_max)
# Creating price breaks will cause the pricing to be updated
self.create_price_breaks()
pricing.update_pricing()
self.assertEqual(pricing.overall_min, Money('2.014667', 'USD'))
self.assertEqual(pricing.overall_max, Money('6.117647', 'USD'))
# Delete all supplier parts and re-calculate
self.part.supplier_parts.all().delete()
pricing.update_pricing()
pricing.refresh_from_db()
self.assertIsNone(pricing.supplier_price_min)
self.assertIsNone(pricing.supplier_price_max)
def test_internal_pricing(self):
"""Tests for internal price breaks"""
# Ensure internal pricing is enabled
common.models.InvenTreeSetting.set_setting('PART_INTERNAL_PRICE', True, None)
pricing = self.part.pricing
# Initially, no internal price breaks
self.assertIsNone(pricing.internal_cost_min)
self.assertIsNone(pricing.internal_cost_max)
currency = common.settings.currency_code_default()
for ii in range(5):
# Let's add some internal price breaks
part.models.PartInternalPriceBreak.objects.create(
part=self.part,
quantity=ii + 1,
price=10 - ii,
price_currency=currency
)
pricing.update_internal_cost()
# Expected money value
m_expected = Money(10 - ii, currency)
# Minimum cost should keep decreasing as we add more items
self.assertEqual(pricing.internal_cost_min, m_expected)
self.assertEqual(pricing.overall_min, m_expected)
# Maximum cost should stay the same
self.assertEqual(pricing.internal_cost_max, Money(10, currency))
self.assertEqual(pricing.overall_max, Money(10, currency))
def test_bom_pricing(self):
"""Unit test for BOM pricing calculations"""
pricing = self.part.pricing
self.assertIsNone(pricing.bom_cost_min)
self.assertIsNone(pricing.bom_cost_max)
currency = 'AUD'
for ii in range(10):
# Create a new part for the BOM
sub_part = part.models.Part.objects.create(
name=f"Sub Part {ii}",
description="A sub part for use in a BOM",
component=True,
assembly=False,
)
# Create some overall pricing
sub_part_pricing = sub_part.pricing
# Manually override internal price
sub_part_pricing.internal_cost_min = Money(2 * (ii + 1), currency)
sub_part_pricing.internal_cost_max = Money(3 * (ii + 1), currency)
sub_part_pricing.save()
part.models.BomItem.objects.create(
part=self.part,
sub_part=sub_part,
quantity=5,
)
pricing.update_bom_cost()
# Check that the values have been updated correctly
self.assertEqual(pricing.currency, 'USD')
# Final overall pricing checks
self.assertEqual(pricing.overall_min, Money('366.666665', 'USD'))
self.assertEqual(pricing.overall_max, Money('550', 'USD'))
def test_purchase_pricing(self):
"""Unit tests for historical purchase pricing"""
self.create_price_breaks()
pricing = self.part.pricing
# Pre-calculation, pricing should be null
self.assertIsNone(pricing.purchase_cost_min)
self.assertIsNone(pricing.purchase_cost_max)
# Generate some purchase orders
po = order.models.PurchaseOrder.objects.create(
supplier=self.supplier_2,
reference='PO-009',
)
# Add some line items to the order
# $5 AUD each
line_1 = po.add_line_item(self.sp_2, quantity=10, purchase_price=Money(5, 'AUD'))
# $30 CAD each (but pack_size is 10, so really $3 CAD each)
line_2 = po.add_line_item(self.sp_3, quantity=5, purchase_price=Money(30, 'CAD'))
pricing.update_purchase_cost()
# Cost is still null, as the order is not complete
self.assertIsNone(pricing.purchase_cost_min)
self.assertIsNone(pricing.purchase_cost_max)
po.status = PurchaseOrderStatus.COMPLETE
po.save()
pricing.update_purchase_cost()
# Cost is still null, as the lines have not been received
self.assertIsNone(pricing.purchase_cost_min)
self.assertIsNone(pricing.purchase_cost_max)
# Mark items as received
line_1.received = 4
line_1.save()
line_2.received = 5
line_2.save()
pricing.update_purchase_cost()
self.assertEqual(pricing.purchase_cost_min, Money('1.333333', 'USD'))
self.assertEqual(pricing.purchase_cost_max, Money('1.764706', 'USD'))

View File

@ -11,10 +11,6 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
from djmoney.contrib.exchange.exceptions import MissingRate
from djmoney.contrib.exchange.models import convert_money
import common.settings as inventree_settings
from common.files import FileManager
from common.models import InvenTreeSetting
from common.views import FileManagementAjaxView, FileManagementFormView
@ -22,7 +18,6 @@ from company.models import SupplierPart
from InvenTree.helpers import str2bool
from InvenTree.views import (AjaxUpdateView, AjaxView, InvenTreeRoleMixin,
QRCodeView)
from order.models import PurchaseOrderLineItem
from plugin.views import InvenTreePluginViewMixin
from stock.models import StockItem, StockLocation
@ -292,17 +287,6 @@ class PartDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
context.update(**ctx)
show_price_history = InvenTreeSetting.get_setting('PART_SHOW_PRICE_HISTORY', False)
context['show_price_history'] = show_price_history
# Pricing information
if show_price_history:
ctx = self.get_pricing(self.get_quantity())
ctx['form'] = self.form_class(initial=self.get_initials())
context.update(ctx)
return context
def get_quantity(self):
@ -313,113 +297,6 @@ class PartDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
"""Return the Part instance associated with this view"""
return self.get_object()
def get_pricing(self, quantity=1, currency=None):
"""Returns context with pricing information."""
ctx = PartPricing.get_pricing(self, quantity, currency)
part = self.get_part()
default_currency = inventree_settings.currency_code_default()
# Stock history
if part.total_stock > 1:
price_history = []
stock = part.stock_entries(include_variants=False, in_stock=True).\
order_by('purchase_order__issue_date').prefetch_related('purchase_order', 'supplier_part')
for stock_item in stock:
if None in [stock_item.purchase_price, stock_item.quantity]:
continue
# convert purchase price to current currency - only one currency in the graph
try:
price = convert_money(stock_item.purchase_price, default_currency)
except MissingRate:
continue
line = {
'price': price.amount,
'qty': stock_item.quantity
}
# Supplier Part Name # TODO use in graph
if stock_item.supplier_part:
line['name'] = stock_item.supplier_part.pretty_name
if stock_item.supplier_part.unit_pricing and price:
line['price_diff'] = price.amount - stock_item.supplier_part.unit_pricing
line['price_part'] = stock_item.supplier_part.unit_pricing
# set date for graph labels
if stock_item.purchase_order and stock_item.purchase_order.issue_date:
line['date'] = stock_item.purchase_order.issue_date.isoformat()
elif stock_item.tracking_info.count() > 0:
line['date'] = stock_item.tracking_info.first().date.date().isoformat()
else:
# Not enough information
continue
price_history.append(line)
ctx['price_history'] = price_history
# BOM Information for Pie-Chart
if part.has_bom:
# get internal price setting
use_internal = InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
ctx_bom_parts = []
# iterate over all bom-items
for item in part.bom_items.all():
ctx_item = {'name': str(item.sub_part)}
price, qty = item.sub_part.get_price_range(quantity, internal=use_internal), item.quantity
price_min, price_max = 0, 0
if price: # check if price available
price_min = str((price[0] * qty) / quantity)
if len(set(price)) == 2: # min and max-price present
price_max = str((price[1] * qty) / quantity)
ctx['bom_pie_max'] = True # enable showing max prices in bom
ctx_item['max_price'] = price_min
ctx_item['min_price'] = price_max if price_max else price_min
ctx_bom_parts.append(ctx_item)
# add to global context
ctx['bom_parts'] = ctx_bom_parts
# Sale price history
sale_items = PurchaseOrderLineItem.objects.filter(part__part=part).order_by('order__issue_date').\
prefetch_related('order', ).all()
if sale_items:
sale_history = []
for sale_item in sale_items:
# check for not fully defined elements
if None in [sale_item.purchase_price, sale_item.quantity]:
continue
try:
price = convert_money(sale_item.purchase_price, default_currency)
except MissingRate:
continue
line = {
'price': price.amount if price else 0,
'qty': sale_item.quantity,
}
# set date for graph labels
if sale_item.order.issue_date:
line['date'] = sale_item.order.issue_date.isoformat()
elif sale_item.order.creation_date:
line['date'] = sale_item.order.creation_date.isoformat()
else:
line['date'] = _('None')
sale_history.append(line)
ctx['sale_history'] = sale_history
return ctx
def get_initials(self):
"""Returns initials for form."""
return {'quantity': self.get_quantity()}
@ -573,6 +450,8 @@ class BomDownload(AjaxView):
manufacturer_data = str2bool(request.GET.get('manufacturer_data', False))
pricing_data = str2bool(request.GET.get('pricing_data', False))
levels = request.GET.get('levels', None)
if levels is not None:
@ -596,6 +475,7 @@ class BomDownload(AjaxView):
stock_data=stock_data,
supplier_data=supplier_data,
manufacturer_data=manufacturer_data,
pricing_data=pricing_data,
)
def get_data(self):