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:
@ -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)
|
||||
|
@ -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'),
|
||||
])),
|
||||
|
@ -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()
|
||||
|
@ -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:
|
||||
|
76
InvenTree/part/migrations/0089_auto_20221112_0128.py
Normal file
76
InvenTree/part/migrations/0089_auto_20221112_0128.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
@ -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.
|
||||
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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');
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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" %}
|
||||
|
@ -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. 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 %}
|
||||
|
80
InvenTree/part/templates/part/pricing_javascript.html
Normal file
80
InvenTree/part/templates/part/pricing_javascript.html
Normal 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 %}
|
@ -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()
|
||||
|
@ -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..."""
|
||||
|
||||
|
@ -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."""
|
||||
|
@ -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
|
||||
|
330
InvenTree/part/test_pricing.py
Normal file
330
InvenTree/part/test_pricing.py
Normal 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'))
|
@ -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):
|
||||
|
Reference in New Issue
Block a user