mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-03 22:55:43 +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