mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
8ceb1af3c3
commit
06266b48af
@ -2,11 +2,21 @@
|
||||
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 80
|
||||
INVENTREE_API_VERSION = 81
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v81 -> 2022-11-08 : https://github.com/inventree/InvenTree/pull/3710
|
||||
- Adds cached pricing information to Part API
|
||||
- Adds cached pricing information to BomItem API
|
||||
- Allows Part and BomItem list endpoints to be filtered by 'has_pricing'
|
||||
- Remove calculated 'price_string' values from API endpoints
|
||||
- Allows PurchaseOrderLineItem API endpoint to be filtered by 'has_pricing'
|
||||
- Allows SalesOrderLineItem API endpoint to be filtered by 'has_pricing'
|
||||
- Allows SalesOrderLineItem API endpoint to be filtered by 'order_status'
|
||||
- Adds more information to SupplierPriceBreak serializer
|
||||
|
||||
v80 -> 2022-11-07 : https://github.com/inventree/InvenTree/pull/3906
|
||||
- Adds 'barcode_hash' to Part API serializer
|
||||
- Adds 'barcode_hash' to StockLocation API serializer
|
||||
|
@ -68,6 +68,13 @@ class InvenTreeConfig(AppConfig):
|
||||
minutes=task.minutes,
|
||||
)
|
||||
|
||||
# Put at least one task onto the backround worker stack,
|
||||
# which will be processed as soon as the worker comes online
|
||||
InvenTree.tasks.offload_task(
|
||||
InvenTree.tasks.heartbeat,
|
||||
force_async=True,
|
||||
)
|
||||
|
||||
logger.info("Started background tasks...")
|
||||
|
||||
def collect_tasks(self):
|
||||
|
@ -67,6 +67,13 @@ class InvenTreeModelMoneyField(ModelMoneyField):
|
||||
# set defaults
|
||||
kwargs.update(money_kwargs())
|
||||
|
||||
# Default values (if not specified)
|
||||
if 'max_digits' not in kwargs:
|
||||
kwargs['max_digits'] = 19
|
||||
|
||||
if 'decimal_places' not in kwargs:
|
||||
kwargs['decimal_places'] = 6
|
||||
|
||||
# Set a minimum value validator
|
||||
validators = kwargs.get('validators', [])
|
||||
|
||||
@ -107,6 +114,10 @@ class InvenTreeMoneyField(MoneyField):
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Override initial values with the real info from database."""
|
||||
kwargs.update(money_kwargs())
|
||||
|
||||
kwargs['max_digits'] = 19
|
||||
kwargs['decimal_places'] = 6
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
|
@ -34,7 +34,7 @@ class InvenTreeMoneySerializer(MoneyField):
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Overrite default values."""
|
||||
kwargs["max_digits"] = kwargs.get("max_digits", 19)
|
||||
self.decimal_places = kwargs["decimal_places"] = kwargs.get("decimal_places", 4)
|
||||
self.decimal_places = kwargs["decimal_places"] = kwargs.get("decimal_places", 6)
|
||||
kwargs["required"] = kwargs.get("required", False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
@ -31,7 +31,7 @@ INVENTREE_NEWS_URL = 'https://inventree.org/news/feed.atom'
|
||||
# Determine if we are running in "test" mode e.g. "manage.py test"
|
||||
TESTING = 'test' in sys.argv
|
||||
|
||||
# Are enviroment variables manipulated by tests? Needs to be set by testing code
|
||||
# Are environment variables manipulated by tests? Needs to be set by testing code
|
||||
TESTING_ENV = False
|
||||
|
||||
# New requirement for django 3.2+
|
||||
@ -678,6 +678,9 @@ CURRENCIES = CONFIG.get(
|
||||
],
|
||||
)
|
||||
|
||||
# Maximum number of decimal places for currency rendering
|
||||
CURRENCY_DECIMAL_PLACES = 6
|
||||
|
||||
# Check that each provided currency is supported
|
||||
for currency in CURRENCIES:
|
||||
if currency not in moneyed.CURRENCIES: # pragma: no cover
|
||||
|
@ -72,7 +72,7 @@ class ViewTests(InvenTreeTestCase):
|
||||
'server',
|
||||
'login',
|
||||
'barcodes',
|
||||
'currencies',
|
||||
'pricing',
|
||||
'parts',
|
||||
'stock',
|
||||
]
|
||||
|
@ -98,6 +98,7 @@ translated_javascript_urls = [
|
||||
re_path(r'^barcode.js', DynamicJsView.as_view(template_name='js/translated/barcode.js'), name='barcode.js'),
|
||||
re_path(r'^bom.js', DynamicJsView.as_view(template_name='js/translated/bom.js'), name='bom.js'),
|
||||
re_path(r'^build.js', DynamicJsView.as_view(template_name='js/translated/build.js'), name='build.js'),
|
||||
re_path(r'^charts.js', DynamicJsView.as_view(template_name='js/translated/charts.js'), name='charts.js'),
|
||||
re_path(r'^company.js', DynamicJsView.as_view(template_name='js/translated/company.js'), name='company.js'),
|
||||
re_path(r'^filters.js', DynamicJsView.as_view(template_name='js/translated/filters.js'), name='filters.js'),
|
||||
re_path(r'^forms.js', DynamicJsView.as_view(template_name='js/translated/forms.js'), name='forms.js'),
|
||||
@ -111,6 +112,7 @@ translated_javascript_urls = [
|
||||
re_path(r'^search.js', DynamicJsView.as_view(template_name='js/translated/search.js'), name='search.js'),
|
||||
re_path(r'^stock.js', DynamicJsView.as_view(template_name='js/translated/stock.js'), name='stock.js'),
|
||||
re_path(r'^plugin.js', DynamicJsView.as_view(template_name='js/translated/plugin.js'), name='plugin.js'),
|
||||
re_path(r'^pricing.js', DynamicJsView.as_view(template_name='js/translated/pricing.js'), name='pricing.js'),
|
||||
re_path(r'^news.js', DynamicJsView.as_view(template_name='js/translated/news.js'), name='news.js'),
|
||||
re_path(r'^tables.js', DynamicJsView.as_view(template_name='js/translated/tables.js'), name='tables.js'),
|
||||
re_path(r'^table_filters.js', DynamicJsView.as_view(template_name='js/translated/table_filters.js'), name='table_filters.js'),
|
||||
|
@ -1054,37 +1054,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'PART_SHOW_PRICE_IN_FORMS': {
|
||||
'name': _('Show Price in Forms'),
|
||||
'description': _('Display part price in some forms'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
# 2021-10-08
|
||||
# This setting exists as an interim solution for https://github.com/inventree/InvenTree/issues/2042
|
||||
# The BOM API can be extremely slow when calculating pricing information "on the fly"
|
||||
# A future solution will solve this properly,
|
||||
# but as an interim step we provide a global to enable / disable BOM pricing
|
||||
'PART_SHOW_PRICE_IN_BOM': {
|
||||
'name': _('Show Price in BOM'),
|
||||
'description': _('Include pricing information in BOM tables'),
|
||||
'default': True,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
# 2022-02-03
|
||||
# This setting exists as an interim solution for extremely slow part page load times when the part has a complex BOM
|
||||
# In an upcoming release, pricing history (and BOM pricing) will be cached,
|
||||
# rather than having to be re-calculated every time the page is loaded!
|
||||
# For now, we will simply hide part pricing by default
|
||||
'PART_SHOW_PRICE_HISTORY': {
|
||||
'name': _('Show Price History'),
|
||||
'description': _('Display historical pricing for Part'),
|
||||
'default': False,
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'PART_SHOW_RELATED': {
|
||||
'name': _('Show related parts'),
|
||||
'description': _('Display related parts for a part'),
|
||||
@ -1099,20 +1068,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
},
|
||||
|
||||
'PART_INTERNAL_PRICE': {
|
||||
'name': _('Internal Prices'),
|
||||
'description': _('Enable internal prices for parts'),
|
||||
'default': False,
|
||||
'validator': bool
|
||||
},
|
||||
|
||||
'PART_BOM_USE_INTERNAL_PRICE': {
|
||||
'name': _('Internal Price as BOM-Price'),
|
||||
'description': _('Use the internal price (if set) in BOM-price calculations'),
|
||||
'default': False,
|
||||
'validator': bool
|
||||
},
|
||||
|
||||
'PART_NAME_FORMAT': {
|
||||
'name': _('Part Name Display Format'),
|
||||
'description': _('Format to display the part name'),
|
||||
@ -1127,6 +1082,42 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'default': '',
|
||||
},
|
||||
|
||||
'PRICING_DECIMAL_PLACES': {
|
||||
'name': _('Pricing Decimal Places'),
|
||||
'description': _('Number of decimal places to display when rendering pricing data'),
|
||||
'default': 6,
|
||||
'validator': [
|
||||
int,
|
||||
MinValueValidator(2),
|
||||
MaxValueValidator(6)
|
||||
]
|
||||
},
|
||||
|
||||
'PRICING_UPDATE_DAYS': {
|
||||
'name': _('Pricing Rebuild Time'),
|
||||
'description': _('Number of days before part pricing is automatically updated'),
|
||||
'units': _('days'),
|
||||
'default': 30,
|
||||
'validator': [
|
||||
int,
|
||||
MinValueValidator(10),
|
||||
]
|
||||
},
|
||||
|
||||
'PART_INTERNAL_PRICE': {
|
||||
'name': _('Internal Prices'),
|
||||
'description': _('Enable internal prices for parts'),
|
||||
'default': False,
|
||||
'validator': bool
|
||||
},
|
||||
|
||||
'PART_BOM_USE_INTERNAL_PRICE': {
|
||||
'name': _('Internal Price Override'),
|
||||
'description': _('If available, internal prices override price range calculations'),
|
||||
'default': False,
|
||||
'validator': bool
|
||||
},
|
||||
|
||||
'LABEL_ENABLE': {
|
||||
'name': _('Enable label printing'),
|
||||
'description': _('Enable label printing from the web interface'),
|
||||
@ -1800,7 +1791,7 @@ class PriceBreak(models.Model):
|
||||
|
||||
price = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
max_digits=19,
|
||||
decimal_places=4,
|
||||
decimal_places=6,
|
||||
null=True,
|
||||
verbose_name=_('Price'),
|
||||
help_text=_('Unit price at specified quantity'),
|
||||
|
@ -173,7 +173,7 @@ class MethodStorageClass:
|
||||
user_settings = {}
|
||||
|
||||
def collect(self, selected_classes=None):
|
||||
"""Collect all classes in the enviroment that are notification methods.
|
||||
"""Collect all classes in the environment that are notification methods.
|
||||
|
||||
Can be filtered to only include provided classes for testing.
|
||||
|
||||
|
@ -57,6 +57,12 @@ class SupplierPartResource(InvenTreeResource):
|
||||
clean_model_instances = True
|
||||
|
||||
|
||||
class SupplierPriceBreakInline(admin.TabularInline):
|
||||
"""Inline for supplier-part pricing"""
|
||||
|
||||
model = SupplierPriceBreak
|
||||
|
||||
|
||||
class SupplierPartAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the SupplierPart model"""
|
||||
|
||||
@ -71,6 +77,10 @@ class SupplierPartAdmin(ImportExportModelAdmin):
|
||||
'SKU',
|
||||
]
|
||||
|
||||
inlines = [
|
||||
SupplierPriceBreakInline,
|
||||
]
|
||||
|
||||
autocomplete_fields = ('part', 'supplier', 'manufacturer_part',)
|
||||
|
||||
|
||||
|
@ -7,6 +7,7 @@ from django_filters import rest_framework as rest_filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import filters
|
||||
|
||||
import part.models
|
||||
from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView
|
||||
from InvenTree.filters import InvenTreeOrderingFilter
|
||||
from InvenTree.helpers import str2bool
|
||||
@ -354,9 +355,6 @@ class SupplierPartList(ListCreateDestroyAPIView):
|
||||
InvenTreeOrderingFilter,
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
]
|
||||
|
||||
ordering_fields = [
|
||||
'SKU',
|
||||
'part',
|
||||
@ -403,6 +401,31 @@ class SupplierPartDetail(RetrieveUpdateDestroyAPI):
|
||||
]
|
||||
|
||||
|
||||
class SupplierPriceBreakFilter(rest_filters.FilterSet):
|
||||
"""Custom API filters for the SupplierPriceBreak list endpoint"""
|
||||
|
||||
base_part = rest_filters.ModelChoiceFilter(
|
||||
label='Base Part',
|
||||
queryset=part.models.Part.objects.all(),
|
||||
field_name='part__part',
|
||||
)
|
||||
|
||||
supplier = rest_filters.ModelChoiceFilter(
|
||||
label='Supplier',
|
||||
queryset=Company.objects.all(),
|
||||
field_name='part__supplier',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
|
||||
model = SupplierPriceBreak
|
||||
fields = [
|
||||
'part',
|
||||
'quantity',
|
||||
]
|
||||
|
||||
|
||||
class SupplierPriceBreakList(ListCreateAPI):
|
||||
"""API endpoint for list view of SupplierPriceBreak object.
|
||||
|
||||
@ -412,15 +435,35 @@ class SupplierPriceBreakList(ListCreateAPI):
|
||||
|
||||
queryset = SupplierPriceBreak.objects.all()
|
||||
serializer_class = SupplierPriceBreakSerializer
|
||||
filterset_class = SupplierPriceBreakFilter
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return serializer instance for this endpoint"""
|
||||
|
||||
try:
|
||||
params = self.request.query_params
|
||||
|
||||
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
|
||||
kwargs['supplier_detail'] = str2bool(params.get('supplier_detail', False))
|
||||
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
'part',
|
||||
ordering_fields = [
|
||||
'quantity',
|
||||
]
|
||||
|
||||
ordering = 'quantity'
|
||||
|
||||
|
||||
class SupplierPriceBreakDetail(RetrieveUpdateDestroyAPI):
|
||||
"""Detail endpoint for SupplierPriceBreak object."""
|
||||
|
@ -0,0 +1,20 @@
|
||||
# Generated by Django 3.2.16 on 2022-11-11 01:50
|
||||
|
||||
import InvenTree.fields
|
||||
from django.db import migrations
|
||||
import djmoney.models.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0050_alter_company_website'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='supplierpricebreak',
|
||||
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'),
|
||||
),
|
||||
]
|
@ -8,6 +8,8 @@ from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q, Sum, UniqueConstraint
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@ -18,6 +20,8 @@ import common.models
|
||||
import common.settings
|
||||
import InvenTree.fields
|
||||
import InvenTree.helpers
|
||||
import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
import InvenTree.validators
|
||||
from common.settings import currency_code_default
|
||||
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
|
||||
@ -691,3 +695,23 @@ class SupplierPriceBreak(common.models.PriceBreak):
|
||||
def __str__(self):
|
||||
"""Format a string representation of a SupplierPriceBreak instance"""
|
||||
return f'{self.part.SKU} - {self.price} @ {self.quantity}'
|
||||
|
||||
|
||||
@receiver(post_save, sender=SupplierPriceBreak, dispatch_uid='post_save_supplier_price_break')
|
||||
def after_save_supplier_price(sender, instance, created, **kwargs):
|
||||
"""Callback function when a SupplierPriceBreak is created or updated"""
|
||||
|
||||
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
|
||||
|
||||
if instance.part and instance.part.part:
|
||||
instance.part.part.pricing.schedule_for_update()
|
||||
|
||||
|
||||
@receiver(post_delete, sender=SupplierPriceBreak, dispatch_uid='post_delete_supplier_price_break')
|
||||
def after_delete_supplier_price(sender, instance, **kwargs):
|
||||
"""Callback function when a SupplierPriceBreak is deleted"""
|
||||
|
||||
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
|
||||
|
||||
if instance.part and instance.part.part:
|
||||
instance.part.part.pricing.schedule_for_update()
|
||||
|
@ -141,7 +141,7 @@ class ManufacturerPartSerializer(InvenTreeModelSerializer):
|
||||
manufacturer_detail = kwargs.pop('manufacturer_detail', True)
|
||||
prettify = kwargs.pop('pretty', False)
|
||||
|
||||
super(ManufacturerPartSerializer, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if part_detail is not True:
|
||||
self.fields.pop('part_detail')
|
||||
@ -205,7 +205,7 @@ class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
|
||||
"""Initialize this serializer with extra detail fields as required"""
|
||||
man_detail = kwargs.pop('manufacturer_part_detail', False)
|
||||
|
||||
super(ManufacturerPartParameterSerializer, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not man_detail:
|
||||
self.fields.pop('manufacturer_part_detail')
|
||||
@ -247,13 +247,17 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
# Check if 'available' quantity was supplied
|
||||
self.has_available_quantity = 'available' in kwargs.get('data', {})
|
||||
|
||||
part_detail = kwargs.pop('part_detail', True)
|
||||
supplier_detail = kwargs.pop('supplier_detail', True)
|
||||
manufacturer_detail = kwargs.pop('manufacturer_detail', True)
|
||||
brief = kwargs.pop('brief', False)
|
||||
|
||||
detail_default = not brief
|
||||
|
||||
part_detail = kwargs.pop('part_detail', detail_default)
|
||||
supplier_detail = kwargs.pop('supplier_detail', detail_default)
|
||||
manufacturer_detail = kwargs.pop('manufacturer_detail', detail_default)
|
||||
|
||||
prettify = kwargs.pop('pretty', False)
|
||||
|
||||
super(SupplierPartSerializer, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if part_detail is not True:
|
||||
self.fields.pop('part_detail')
|
||||
@ -263,6 +267,7 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
|
||||
if manufacturer_detail is not True:
|
||||
self.fields.pop('manufacturer_detail')
|
||||
self.fields.pop('manufacturer_part_detail')
|
||||
|
||||
if prettify is not True:
|
||||
self.fields.pop('pretty_name')
|
||||
@ -366,6 +371,20 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for SupplierPriceBreak object."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize this serializer with extra fields as required"""
|
||||
|
||||
supplier_detail = kwargs.pop('supplier_detail', False)
|
||||
part_detail = kwargs.pop('part_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not supplier_detail:
|
||||
self.fields.pop('supplier_detail')
|
||||
|
||||
if not part_detail:
|
||||
self.fields.pop('part_detail')
|
||||
|
||||
quantity = InvenTreeDecimalField()
|
||||
|
||||
price = InvenTreeMoneySerializer(
|
||||
@ -380,6 +399,13 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
|
||||
label=_('Currency'),
|
||||
)
|
||||
|
||||
supplier = serializers.PrimaryKeyRelatedField(source='part.supplier', many=False, read_only=True)
|
||||
|
||||
supplier_detail = CompanyBriefSerializer(source='part.supplier', many=False, read_only=True)
|
||||
|
||||
# Detail serializer for SupplierPart
|
||||
part_detail = SupplierPartSerializer(source='part', brief=True, many=False, read_only=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
@ -387,8 +413,11 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
|
||||
fields = [
|
||||
'pk',
|
||||
'part',
|
||||
'part_detail',
|
||||
'quantity',
|
||||
'price',
|
||||
'price_currency',
|
||||
'supplier',
|
||||
'supplier_detail',
|
||||
'updated',
|
||||
]
|
||||
|
@ -252,6 +252,9 @@ src="{% static 'img/blank_image.png' %}"
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='price-break-toolbar' class='btn-group'>
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "filter_list.html" with id='supplierpricebreak' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' id='price-break-table' data-toolbar='#price-break-toolbar'>
|
||||
@ -291,82 +294,8 @@ $("#barcode-unlink").click(function() {
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
function reloadPriceBreaks() {
|
||||
$("#price-break-table").bootstrapTable("refresh");
|
||||
}
|
||||
|
||||
$('#price-break-table').inventreeTable({
|
||||
name: 'buypricebreaks',
|
||||
formatNoMatches: function() { return "{% trans "No price break information found" %}"; },
|
||||
queryParams: {
|
||||
part: {{ part.id }},
|
||||
},
|
||||
url: "{% url 'api-part-supplier-price-list' %}",
|
||||
onPostBody: function() {
|
||||
var table = $('#price-break-table');
|
||||
|
||||
table.find('.button-price-break-delete').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`/api/company/price-break/${pk}/`, {
|
||||
method: 'DELETE',
|
||||
onSuccess: reloadPriceBreaks,
|
||||
title: '{% trans "Delete Price Break" %}',
|
||||
});
|
||||
});
|
||||
|
||||
table.find('.button-price-break-edit').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`/api/company/price-break/${pk}/`, {
|
||||
fields: {
|
||||
quantity: {},
|
||||
price: {},
|
||||
price_currency: {},
|
||||
},
|
||||
onSuccess: reloadPriceBreaks,
|
||||
title: '{% trans "Edit Price Break" %}',
|
||||
});
|
||||
});
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
field: 'pk',
|
||||
title: 'ID',
|
||||
visible: false,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'quantity',
|
||||
title: '{% trans "Quantity" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'price',
|
||||
title: '{% trans "Price" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index) {
|
||||
var html = value;
|
||||
|
||||
html += `<div class='btn-group float-right' role='group'>`
|
||||
|
||||
html += makeIconButton('fa-edit icon-blue', 'button-price-break-edit', row.pk, '{% trans "Edit price break" %}');
|
||||
html += makeIconButton('fa-trash-alt icon-red', 'button-price-break-delete', row.pk, '{% trans "Delete price break" %}');
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'updated',
|
||||
title: '{% trans "Last updated" %}',
|
||||
sortable: true,
|
||||
formatter: function(value) {
|
||||
return renderDate(value);
|
||||
}
|
||||
},
|
||||
]
|
||||
loadSupplierPriceBreakTable({
|
||||
part: {{ part.pk }}
|
||||
});
|
||||
|
||||
$('#new-price-break').click(function() {
|
||||
@ -386,7 +315,9 @@ $('#new-price-break').click(function() {
|
||||
},
|
||||
},
|
||||
title: '{% trans "Add Price Break" %}',
|
||||
onSuccess: reloadPriceBreaks,
|
||||
onSuccess: function() {
|
||||
$("#price-break-table").bootstrapTable("refresh");
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -239,12 +239,6 @@ class ManufacturerTest(InvenTreeAPITestCase):
|
||||
# Check link is not modified
|
||||
self.assertEqual(response.data['link'], 'https://www.axel-larsson.se/Exego.aspx?p_id=341&ArtNr=0804020E')
|
||||
|
||||
# Check manufacturer part
|
||||
manufacturer_part_id = int(response.data['manufacturer_part_detail']['pk'])
|
||||
url = reverse('api-manufacturer-part-detail', kwargs={'pk': manufacturer_part_id})
|
||||
response = self.get(url)
|
||||
self.assertEqual(response.data['MPN'], 'PART_NUMBER')
|
||||
|
||||
# Check link is not modified
|
||||
self.assertEqual(response.data['link'], 'https://www.axel-larsson.se/Exego.aspx?p_id=341&ArtNr=0804020E')
|
||||
|
||||
|
@ -445,6 +445,19 @@ class PurchaseOrderLineItemFilter(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 by whether or not the line item has pricing information"""
|
||||
value = str2bool(value)
|
||||
|
||||
if value:
|
||||
queryset = queryset.exclude(purchase_price=None)
|
||||
else:
|
||||
queryset = queryset.filter(purchase_price=None)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class PurchaseOrderLineItemList(APIDownloadMixin, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of PurchaseOrderLineItem objects.
|
||||
@ -776,6 +789,22 @@ class SalesOrderLineItemFilter(rest_filters.FilterSet):
|
||||
'part',
|
||||
]
|
||||
|
||||
has_pricing = rest_filters.BooleanFilter(label="Has Pricing", method='filter_has_pricing')
|
||||
|
||||
def filter_has_pricing(self, queryset, name, value):
|
||||
"""Filter by whether or not the line item has pricing information"""
|
||||
|
||||
value = str2bool(value)
|
||||
|
||||
if value:
|
||||
queryset = queryset.exclude(sale_price=None)
|
||||
else:
|
||||
queryset = queryset.filter(sale_price=None)
|
||||
|
||||
return queryset
|
||||
|
||||
order_status = rest_filters.NumberFilter(label='Order Status', field_name='order__status')
|
||||
|
||||
completed = rest_filters.BooleanFilter(label='completed', method='filter_completed')
|
||||
|
||||
def filter_completed(self, queryset, name, value):
|
||||
@ -810,6 +839,8 @@ class SalesOrderLineItemList(ListCreateAPI):
|
||||
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
|
||||
kwargs['order_detail'] = str2bool(params.get('order_detail', False))
|
||||
kwargs['allocations'] = str2bool(params.get('allocations', False))
|
||||
kwargs['customer_detail'] = str2bool(params.get('customer_detail', False))
|
||||
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
@ -853,11 +884,6 @@ class SalesOrderLineItemList(ListCreateAPI):
|
||||
'reference',
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
'order',
|
||||
'part',
|
||||
]
|
||||
|
||||
|
||||
class SalesOrderExtraLineList(GeneralExtraLineList, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of SalesOrderExtraLine objects."""
|
||||
|
35
InvenTree/order/migrations/0076_auto_20221111_0153.py
Normal file
35
InvenTree/order/migrations/0076_auto_20221111_0153.py
Normal file
@ -0,0 +1,35 @@
|
||||
# Generated by Django 3.2.16 on 2022-11-11 01:53
|
||||
|
||||
import InvenTree.fields
|
||||
from django.db import migrations
|
||||
import djmoney.models.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0075_auto_20221110_0108'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorderextraline',
|
||||
name='price',
|
||||
field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Unit price', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchaseorderlineitem',
|
||||
name='purchase_price',
|
||||
field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Unit purchase price', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Purchase Price'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorderextraline',
|
||||
name='price',
|
||||
field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Unit price', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesorderlineitem',
|
||||
name='sale_price',
|
||||
field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Unit sale price', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Sale Price'),
|
||||
),
|
||||
]
|
@ -24,6 +24,7 @@ from mptt.models import TreeForeignKey
|
||||
|
||||
import InvenTree.helpers
|
||||
import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
import order.validators
|
||||
from common.notifications import InvenTreeNotificationBodies
|
||||
from common.settings import currency_code_default
|
||||
@ -311,6 +312,9 @@ class PurchaseOrder(Order):
|
||||
reference (str, optional): Reference to item. Defaults to ''.
|
||||
purchase_price (optional): Price of item. Defaults to None.
|
||||
|
||||
Returns:
|
||||
The newly created PurchaseOrderLineItem instance
|
||||
|
||||
Raises:
|
||||
ValidationError: quantity is smaller than 0
|
||||
ValidationError: quantity is not type int
|
||||
@ -338,11 +342,13 @@ class PurchaseOrder(Order):
|
||||
quantity_new = line.quantity + quantity
|
||||
line.quantity = quantity_new
|
||||
supplier_price = supplier_part.get_price(quantity_new)
|
||||
|
||||
if line.purchase_price and supplier_price:
|
||||
line.purchase_price = supplier_price / quantity_new
|
||||
|
||||
line.save()
|
||||
|
||||
return
|
||||
return line
|
||||
|
||||
line = PurchaseOrderLineItem(
|
||||
order=self,
|
||||
@ -354,6 +360,8 @@ class PurchaseOrder(Order):
|
||||
|
||||
line.save()
|
||||
|
||||
return line
|
||||
|
||||
@transaction.atomic
|
||||
def place_order(self):
|
||||
"""Marks the PurchaseOrder as PLACED.
|
||||
@ -376,8 +384,14 @@ class PurchaseOrder(Order):
|
||||
if self.status == PurchaseOrderStatus.PLACED:
|
||||
self.status = PurchaseOrderStatus.COMPLETE
|
||||
self.complete_date = datetime.now().date()
|
||||
|
||||
self.save()
|
||||
|
||||
# Schedule pricing update for any referenced parts
|
||||
for line in self.lines.all():
|
||||
if line.part and line.part.part:
|
||||
line.part.part.pricing.schedule_for_update()
|
||||
|
||||
trigger_event('purchaseorder.completed', id=self.pk)
|
||||
|
||||
@property
|
||||
@ -762,6 +776,10 @@ class SalesOrder(Order):
|
||||
|
||||
self.save()
|
||||
|
||||
# Schedule pricing update for any referenced parts
|
||||
for line in self.lines.all():
|
||||
line.part.pricing.schedule_for_update()
|
||||
|
||||
trigger_event('salesorder.completed', id=self.pk)
|
||||
|
||||
return True
|
||||
@ -951,7 +969,7 @@ class OrderExtraLine(OrderLineItem):
|
||||
|
||||
price = InvenTreeModelMoneyField(
|
||||
max_digits=19,
|
||||
decimal_places=4,
|
||||
decimal_places=6,
|
||||
null=True, blank=True,
|
||||
allow_negative=True,
|
||||
verbose_name=_('Price'),
|
||||
@ -1031,7 +1049,7 @@ class PurchaseOrderLineItem(OrderLineItem):
|
||||
|
||||
purchase_price = InvenTreeModelMoneyField(
|
||||
max_digits=19,
|
||||
decimal_places=4,
|
||||
decimal_places=6,
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Purchase Price'),
|
||||
help_text=_('Unit purchase price'),
|
||||
@ -1137,7 +1155,7 @@ class SalesOrderLineItem(OrderLineItem):
|
||||
|
||||
sale_price = InvenTreeModelMoneyField(
|
||||
max_digits=19,
|
||||
decimal_places=4,
|
||||
decimal_places=6,
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Sale Price'),
|
||||
help_text=_('Unit sale price'),
|
||||
|
@ -39,8 +39,6 @@ class AbstractOrderSerializer(serializers.Serializer):
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
total_price_string = serializers.CharField(source='get_total_price', read_only=True)
|
||||
|
||||
|
||||
class AbstractExtraLineSerializer(serializers.Serializer):
|
||||
"""Abstract Serializer for a ExtraLine object."""
|
||||
@ -60,8 +58,6 @@ class AbstractExtraLineSerializer(serializers.Serializer):
|
||||
allow_null=True
|
||||
)
|
||||
|
||||
price_string = serializers.CharField(source='price', read_only=True)
|
||||
|
||||
price_currency = serializers.ChoiceField(
|
||||
choices=currency_code_mappings(),
|
||||
help_text=_('Price currency'),
|
||||
@ -81,7 +77,6 @@ class AbstractExtraLineMeta:
|
||||
'order_detail',
|
||||
'price',
|
||||
'price_currency',
|
||||
'price_string',
|
||||
]
|
||||
|
||||
|
||||
@ -164,7 +159,6 @@ class PurchaseOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer)
|
||||
'target_date',
|
||||
'notes',
|
||||
'total_price',
|
||||
'total_price_string',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
@ -326,8 +320,6 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
|
||||
allow_null=True
|
||||
)
|
||||
|
||||
purchase_price_string = serializers.CharField(source='purchase_price', read_only=True)
|
||||
|
||||
destination_detail = stock.serializers.LocationBriefSerializer(source='get_destination', read_only=True)
|
||||
|
||||
purchase_price_currency = serializers.ChoiceField(
|
||||
@ -387,7 +379,6 @@ class PurchaseOrderLineItemSerializer(InvenTreeModelSerializer):
|
||||
'received',
|
||||
'purchase_price',
|
||||
'purchase_price_currency',
|
||||
'purchase_price_string',
|
||||
'destination',
|
||||
'destination_detail',
|
||||
'target_date',
|
||||
@ -745,7 +736,6 @@ class SalesOrderSerializer(AbstractOrderSerializer, InvenTreeModelSerializer):
|
||||
'shipment_date',
|
||||
'target_date',
|
||||
'total_price',
|
||||
'total_price_string',
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
@ -870,6 +860,7 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
|
||||
part_detail = kwargs.pop('part_detail', False)
|
||||
order_detail = kwargs.pop('order_detail', False)
|
||||
allocations = kwargs.pop('allocations', False)
|
||||
customer_detail = kwargs.pop('customer_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@ -882,6 +873,10 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
|
||||
if allocations is not True:
|
||||
self.fields.pop('allocations')
|
||||
|
||||
if customer_detail is not True:
|
||||
self.fields.pop('customer_detail')
|
||||
|
||||
customer_detail = CompanyBriefSerializer(source='order.customer', many=False, read_only=True)
|
||||
order_detail = SalesOrderSerializer(source='order', many=False, read_only=True)
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
allocations = SalesOrderAllocationSerializer(many=True, read_only=True, location_detail=True)
|
||||
@ -900,8 +895,6 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
|
||||
allow_null=True
|
||||
)
|
||||
|
||||
sale_price_string = serializers.CharField(source='sale_price', read_only=True)
|
||||
|
||||
sale_price_currency = serializers.ChoiceField(
|
||||
choices=currency_code_mappings(),
|
||||
help_text=_('Sale price currency'),
|
||||
@ -917,6 +910,7 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
|
||||
'allocated',
|
||||
'allocations',
|
||||
'available_stock',
|
||||
'customer_detail',
|
||||
'quantity',
|
||||
'reference',
|
||||
'notes',
|
||||
@ -927,7 +921,6 @@ class SalesOrderLineItemSerializer(InvenTreeModelSerializer):
|
||||
'part_detail',
|
||||
'sale_price',
|
||||
'sale_price_currency',
|
||||
'sale_price_string',
|
||||
'shipped',
|
||||
'target_date',
|
||||
]
|
||||
|
@ -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):
|
||||
|
@ -13,7 +13,7 @@ class ActionMixinTests(TestCase):
|
||||
ACTION_RETURN = 'a action was performed'
|
||||
|
||||
def setUp(self):
|
||||
"""Setup enviroment for tests.
|
||||
"""Setup environment for tests.
|
||||
|
||||
Contains multiple sample plugins that are used in the tests
|
||||
"""
|
||||
|
@ -121,8 +121,10 @@ def allow_table_event(table_name):
|
||||
|
||||
ignore_tables = [
|
||||
'common_notificationentry',
|
||||
'common_notificationmessage',
|
||||
'common_webhookendpoint',
|
||||
'common_webhookmessage',
|
||||
'part_partpricing',
|
||||
]
|
||||
|
||||
if table_name in ignore_tables:
|
||||
|
@ -311,7 +311,7 @@ class PluginsRegistry:
|
||||
return collected_plugins
|
||||
|
||||
def install_plugin_file(self):
|
||||
"""Make sure all plugins are installed in the current enviroment."""
|
||||
"""Make sure all plugins are installed in the current environment."""
|
||||
if settings.PLUGIN_FILE_CHECKED:
|
||||
logger.info('Plugin file was already checked')
|
||||
return True
|
||||
|
@ -198,7 +198,7 @@ class RegistryTests(TestCase):
|
||||
def run_package_test(self, directory):
|
||||
"""General runner for testing package based installs."""
|
||||
|
||||
# Patch enviroment varible to add dir
|
||||
# Patch environment varible to add dir
|
||||
envs = {'INVENTREE_PLUGIN_TEST_DIR': directory}
|
||||
with mock.patch.dict(os.environ, envs):
|
||||
# Reload to redicsover plugins
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""Admin for stock 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
|
||||
@ -19,9 +20,15 @@ from .models import (StockItem, StockItemAttachment, StockItemTestResult,
|
||||
class LocationResource(InvenTreeResource):
|
||||
"""Class for managing StockLocation data import/export."""
|
||||
|
||||
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(StockLocation))
|
||||
id = Field(attribute='pk', column_name=_('Location ID'))
|
||||
name = Field(attribute='name', column_name=_('Location Name'))
|
||||
description = Field(attribute='description', column_name=_('Description'))
|
||||
parent = Field(attribute='parent', column_name=_('Parent ID'), widget=widgets.ForeignKeyWidget(StockLocation))
|
||||
parent_name = Field(attribute='parent__name', column_name=_('Parent Name'), readonly=True)
|
||||
pathstring = Field(attribute='pathstring', column_name=_('Location Path'))
|
||||
|
||||
parent_name = Field(attribute='parent__name', readonly=True)
|
||||
# Calculated fields
|
||||
items = Field(attribute='item_count', column_name=_('Stock Items'), widget=widgets.IntegerWidget())
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
@ -35,6 +42,8 @@ class LocationResource(InvenTreeResource):
|
||||
# Exclude MPTT internal model fields
|
||||
'lft', 'rght', 'tree_id', 'level',
|
||||
'metadata',
|
||||
'barcode_data', 'barcode_hash',
|
||||
'owner', 'icon',
|
||||
]
|
||||
|
||||
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
|
||||
@ -71,39 +80,32 @@ class LocationAdmin(ImportExportModelAdmin):
|
||||
class StockItemResource(InvenTreeResource):
|
||||
"""Class for managing StockItem data import/export."""
|
||||
|
||||
# Custom managers for ForeignKey fields
|
||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
|
||||
|
||||
part_name = Field(attribute='part__full_name', readonly=True)
|
||||
|
||||
supplier_part = Field(attribute='supplier_part', widget=widgets.ForeignKeyWidget(SupplierPart))
|
||||
|
||||
supplier = Field(attribute='supplier_part__supplier__id', readonly=True)
|
||||
|
||||
customer = Field(attribute='customer', widget=widgets.ForeignKeyWidget(Company))
|
||||
|
||||
supplier_name = Field(attribute='supplier_part__supplier__name', readonly=True)
|
||||
|
||||
status_label = Field(attribute='status_label', readonly=True)
|
||||
|
||||
location = Field(attribute='location', widget=widgets.ForeignKeyWidget(StockLocation))
|
||||
|
||||
location_name = Field(attribute='location__name', readonly=True)
|
||||
|
||||
belongs_to = Field(attribute='belongs_to', widget=widgets.ForeignKeyWidget(StockItem))
|
||||
|
||||
build = Field(attribute='build', widget=widgets.ForeignKeyWidget(Build))
|
||||
|
||||
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(StockItem))
|
||||
|
||||
sales_order = Field(attribute='sales_order', widget=widgets.ForeignKeyWidget(SalesOrder))
|
||||
|
||||
purchase_order = Field(attribute='purchase_order', widget=widgets.ForeignKeyWidget(PurchaseOrder))
|
||||
id = Field(attribute='pk', column_name=_('Stock Item ID'))
|
||||
part = Field(attribute='part', column_name=_('Part ID'), widget=widgets.ForeignKeyWidget(Part))
|
||||
part_name = Field(attribute='part__full_name', column_name=_('Part Name'), readonly=True)
|
||||
quantity = Field(attribute='quantity', column_name=_('Quantity'))
|
||||
serial = Field(attribute='serial', column_name=_('Serial'))
|
||||
batch = Field(attribute='batch', column_name=_('Batch'))
|
||||
status_label = Field(attribute='status_label', column_name=_('Status'), readonly=True)
|
||||
location = Field(attribute='location', column_name=_('Location ID'), widget=widgets.ForeignKeyWidget(StockLocation))
|
||||
location_name = Field(attribute='location__name', column_name=_('Location Name'), readonly=True)
|
||||
supplier_part = Field(attribute='supplier_part', column_name=_('Supplier Part ID'), widget=widgets.ForeignKeyWidget(SupplierPart))
|
||||
supplier = Field(attribute='supplier_part__supplier__id', column_name=_('Supplier ID'), readonly=True)
|
||||
supplier_name = Field(attribute='supplier_part__supplier__name', column_name=_('Supplier Name'), readonly=True)
|
||||
customer = Field(attribute='customer', column_name=_('Customer ID'), widget=widgets.ForeignKeyWidget(Company))
|
||||
belongs_to = Field(attribute='belongs_to', column_name=_('Installed In'), widget=widgets.ForeignKeyWidget(StockItem))
|
||||
build = Field(attribute='build', column_name=_('Build ID'), widget=widgets.ForeignKeyWidget(Build))
|
||||
parent = Field(attribute='parent', column_name=_('Parent ID'), widget=widgets.ForeignKeyWidget(StockItem))
|
||||
sales_order = Field(attribute='sales_order', column_name=_('Sales Order ID'), widget=widgets.ForeignKeyWidget(SalesOrder))
|
||||
purchase_order = Field(attribute='purchase_order', column_name=_('Purchase Order ID'), widget=widgets.ForeignKeyWidget(PurchaseOrder))
|
||||
packaging = Field(attribute='packaging', column_name=_('Packaging'))
|
||||
link = Field(attribute='link', column_name=_('Link'))
|
||||
notes = Field(attribute='notes', column_name=_('Notes'))
|
||||
|
||||
# Date management
|
||||
updated = Field(attribute='updated', widget=widgets.DateWidget())
|
||||
|
||||
stocktake_date = Field(attribute='stocktake_date', widget=widgets.DateWidget())
|
||||
updated = Field(attribute='updated', column_name=_('Last Updated'), widget=widgets.DateWidget())
|
||||
stocktake_date = Field(attribute='stocktake_date', column_name=_('Stocktake'), widget=widgets.DateWidget())
|
||||
expiry_date = Field(attribute='expiry_date', column_name=_('Expiry Date'), widget=widgets.DateWidget())
|
||||
|
||||
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
|
||||
"""Rebuild after import to keep tree intact."""
|
||||
@ -125,6 +127,8 @@ class StockItemResource(InvenTreeResource):
|
||||
'lft', 'rght', 'tree_id', 'level',
|
||||
# Exclude internal fields
|
||||
'serial_int', 'metadata',
|
||||
'barcode_hash', 'barcode_data',
|
||||
'owner',
|
||||
]
|
||||
|
||||
|
||||
|
@ -35,7 +35,7 @@ from order.serializers import PurchaseOrderSerializer
|
||||
from part.models import BomItem, Part, PartCategory
|
||||
from part.serializers import PartBriefSerializer
|
||||
from plugin.serializers import MetadataSerializer
|
||||
from stock.admin import StockItemResource
|
||||
from stock.admin import LocationResource, StockItemResource
|
||||
from stock.models import (StockItem, StockItemAttachment, StockItemTestResult,
|
||||
StockItemTracking, StockLocation)
|
||||
|
||||
@ -215,7 +215,7 @@ class StockMerge(CreateAPI):
|
||||
return ctx
|
||||
|
||||
|
||||
class StockLocationList(ListCreateAPI):
|
||||
class StockLocationList(APIDownloadMixin, ListCreateAPI):
|
||||
"""API endpoint for list view of StockLocation objects.
|
||||
|
||||
- GET: Return list of StockLocation objects
|
||||
@ -225,6 +225,15 @@ class StockLocationList(ListCreateAPI):
|
||||
queryset = StockLocation.objects.all()
|
||||
serializer_class = StockSerializers.LocationSerializer
|
||||
|
||||
def download_queryset(self, queryset, export_format):
|
||||
"""Download the filtered queryset as a data file"""
|
||||
|
||||
dataset = LocationResource().export(queryset=queryset)
|
||||
filedata = dataset.export(export_format)
|
||||
filename = f"InvenTree_Locations.{export_format}"
|
||||
|
||||
return DownloadFile(filedata, filename)
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
"""Return annotated queryset for the StockLocationList endpoint"""
|
||||
|
||||
|
@ -0,0 +1,20 @@
|
||||
# Generated by Django 3.2.16 on 2022-11-11 01:53
|
||||
|
||||
import InvenTree.fields
|
||||
from django.db import migrations
|
||||
import djmoney.models.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0088_remove_stockitem_infinite'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='stockitem',
|
||||
name='purchase_price',
|
||||
field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Single unit purchase price at time of purchase', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Purchase Price'),
|
||||
),
|
||||
]
|
@ -751,7 +751,7 @@ class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
|
||||
purchase_price = InvenTreeModelMoneyField(
|
||||
max_digits=19,
|
||||
decimal_places=4,
|
||||
decimal_places=6,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Purchase Price'),
|
||||
|
@ -171,7 +171,7 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
|
||||
purchase_price = InvenTree.serializers.InvenTreeMoneySerializer(
|
||||
label=_('Purchase Price'),
|
||||
max_digits=19, decimal_places=4,
|
||||
max_digits=19, decimal_places=6,
|
||||
allow_null=True,
|
||||
help_text=_('Purchase price of this stock item'),
|
||||
)
|
||||
@ -183,16 +183,6 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
help_text=_('Purchase currency of this stock item'),
|
||||
)
|
||||
|
||||
purchase_price_string = serializers.SerializerMethodField()
|
||||
|
||||
def get_purchase_price_string(self, obj):
|
||||
"""Return purchase price as string."""
|
||||
if obj.purchase_price:
|
||||
obj.purchase_price.decimal_places_display = 4
|
||||
return str(obj.purchase_price)
|
||||
|
||||
return '-'
|
||||
|
||||
purchase_order_reference = serializers.CharField(source='purchase_order.reference', read_only=True)
|
||||
sales_order_reference = serializers.CharField(source='sales_order.reference', read_only=True)
|
||||
|
||||
@ -253,7 +243,6 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
'updated',
|
||||
'purchase_price',
|
||||
'purchase_price_currency',
|
||||
'purchase_price_string',
|
||||
]
|
||||
|
||||
"""
|
||||
|
@ -438,12 +438,13 @@ class StockItemListTest(StockAPITestCase):
|
||||
|
||||
# Expected headers
|
||||
headers = [
|
||||
'part',
|
||||
'customer',
|
||||
'location',
|
||||
'parent',
|
||||
'quantity',
|
||||
'status',
|
||||
'Part ID',
|
||||
'Customer ID',
|
||||
'Location ID',
|
||||
'Location Name',
|
||||
'Parent ID',
|
||||
'Quantity',
|
||||
'Status',
|
||||
]
|
||||
|
||||
for h in headers:
|
||||
@ -685,9 +686,8 @@ class StockItemTest(StockAPITestCase):
|
||||
data = self.get(url, expected_code=200).data
|
||||
|
||||
# Check fixture values
|
||||
self.assertEqual(data['purchase_price'], '123.0000')
|
||||
self.assertEqual(data['purchase_price'], '123.000000')
|
||||
self.assertEqual(data['purchase_price_currency'], 'AUD')
|
||||
self.assertEqual(data['purchase_price_string'], 'A$123.0000')
|
||||
|
||||
# Update just the amount
|
||||
data = self.patch(
|
||||
@ -698,7 +698,7 @@ class StockItemTest(StockAPITestCase):
|
||||
expected_code=200
|
||||
).data
|
||||
|
||||
self.assertEqual(data['purchase_price'], '456.0000')
|
||||
self.assertEqual(data['purchase_price'], '456.000000')
|
||||
self.assertEqual(data['purchase_price_currency'], 'AUD')
|
||||
|
||||
# Update the currency
|
||||
@ -722,7 +722,6 @@ class StockItemTest(StockAPITestCase):
|
||||
).data
|
||||
|
||||
self.assertEqual(data['purchase_price'], None)
|
||||
self.assertEqual(data['purchase_price_string'], '-')
|
||||
|
||||
# Invalid currency code
|
||||
data = self.patch(
|
||||
|
@ -1,57 +0,0 @@
|
||||
{% extends "panel.html" %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block label %}currencies{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Currency Settings" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tbody>
|
||||
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-globe" %}
|
||||
|
||||
<tr>
|
||||
<td></td>
|
||||
<th>{% trans "Base Currency" %}</th>
|
||||
<th>{{ base_currency }}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<th colspan='4'>{% trans "Exchange Rates" %}</th>
|
||||
</tr>
|
||||
{% for rate in rates %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>{{ rate.value }}</td>
|
||||
<td>{{ rate.currency }}</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>
|
||||
{% trans "Last Update" %}
|
||||
</th>
|
||||
<td colspan="3">
|
||||
{% if rates_updated %}
|
||||
{{ rates_updated }}
|
||||
{% else %}
|
||||
<em>{% trans "Never" %}</em>
|
||||
{% endif %}
|
||||
<form action='{% url "settings-currencies-refresh" %}' method='post'>
|
||||
<div id='refresh-rates-form'>
|
||||
{% csrf_token %}
|
||||
<button type='submit' id='update-rates' class='btn btn-primary float-right'>{% trans "Update Now" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
@ -15,9 +15,6 @@
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_EDIT_IPN" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_NAME_FORMAT" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_HISTORY" icon="fa-history" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_IN_FORMS" icon="fa-dollar-sign" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_IN_BOM" icon="fa-dollar-sign" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_RELATED" icon="fa-random" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_CREATE_INITIAL" icon="fa-boxes" %}
|
||||
<tr><td colspan='5'></td></tr>
|
||||
@ -34,9 +31,6 @@
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_COPY_TESTS" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_CATEGORY_PARAMETERS" %}
|
||||
<tr><td colspan='5'></td></tr>
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_INTERNAL_PRICE" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_BOM_USE_INTERNAL_PRICE" %}
|
||||
<tr><td colspan='5'></td></tr>
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_CATEGORY_DEFAULT_ICON" icon="fa-icons" %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
74
InvenTree/templates/InvenTree/settings/pricing.html
Normal file
74
InvenTree/templates/InvenTree/settings/pricing.html
Normal file
@ -0,0 +1,74 @@
|
||||
{% extends "panel.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block label %}pricing{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Pricing Settings" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block panel_content %}
|
||||
<div class='panel-content'>
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tbody>
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_INTERNAL_PRICE" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_BOM_USE_INTERNAL_PRICE" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PRICING_DECIMAL_PLACES" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PRICING_UPDATE_DAYS" icon='fa-calendar-alt' %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Currency Settings" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
<form action='{% url "settings-currencies-refresh" %}' method='post'>
|
||||
<div id='refresh-rates-form'>
|
||||
{% csrf_token %}
|
||||
<button type='submit' id='update-rates' class='btn btn-primary float-right'>{% trans "Update Now" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% if rates_updated %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% trans "Last Update" %} - {{ rates_updated }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% trans "Last Update" %} - {% trans "Never" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tbody>
|
||||
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-globe" %}
|
||||
|
||||
<tr>
|
||||
<td></td>
|
||||
<th>{% trans "Base Currency" %}</th>
|
||||
<th>{{ base_currency }}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<th>{% trans "Exchange Rates" %}</th>
|
||||
<th>{% trans "Currency" %}</th>
|
||||
<th>{% trans "Rate" %}</th>
|
||||
</tr>
|
||||
{% for rate in rates %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>{{ rate.currency }}</td>
|
||||
<td>{{ rate.value }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock panel_content %}
|
@ -32,10 +32,10 @@
|
||||
{% include "InvenTree/settings/global.html" %}
|
||||
{% include "InvenTree/settings/login.html" %}
|
||||
{% include "InvenTree/settings/barcode.html" %}
|
||||
{% include "InvenTree/settings/currencies.html" %}
|
||||
{% include "InvenTree/settings/label.html" %}
|
||||
{% include "InvenTree/settings/report.html" %}
|
||||
{% include "InvenTree/settings/part.html" %}
|
||||
{% include "InvenTree/settings/pricing.html" %}
|
||||
{% include "InvenTree/settings/category.html" %}
|
||||
{% include "InvenTree/settings/stock.html" %}
|
||||
{% include "InvenTree/settings/build.html" %}
|
||||
|
@ -32,8 +32,8 @@
|
||||
{% include "sidebar_item.html" with label='login' text=text icon="fa-fingerprint" %}
|
||||
{% trans "Barcode Support" as text %}
|
||||
{% include "sidebar_item.html" with label='barcodes' text=text icon="fa-qrcode" %}
|
||||
{% trans "Currencies" as text %}
|
||||
{% include "sidebar_item.html" with label='currencies' text=text icon="fa-dollar-sign" %}
|
||||
{% trans "Pricing" as text %}
|
||||
{% include "sidebar_item.html" with label='pricing' text=text icon="fa-dollar-sign" %}
|
||||
{% trans "Label Printing" as text %}
|
||||
{% include "sidebar_item.html" with label='labels' text=text icon='fa-tag' %}
|
||||
{% trans "Reporting" as text %}
|
||||
|
@ -154,6 +154,7 @@
|
||||
<script defer type='text/javascript' src="{% i18n_static 'barcode.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'bom.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'build.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'charts.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'company.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'filters.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'forms.js' %}"></script>
|
||||
@ -167,6 +168,7 @@
|
||||
<script defer type='text/javascript' src="{% i18n_static 'search.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'stock.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'plugin.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'pricing.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'news.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'tables.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'table_filters.js' %}"></script>
|
||||
|
@ -353,12 +353,25 @@ function exportBom(part_id, options={}) {
|
||||
help_text: '{% trans "Include part supplier data in exported BOM" %}',
|
||||
type: 'boolean',
|
||||
value: inventreeLoad('bom-export-supplier_data', false),
|
||||
},
|
||||
pricing_data: {
|
||||
label: '{% trans "Include Pricing Data" %}',
|
||||
help_text: '{% trans "Include part pricing data in exported BOM" %}',
|
||||
type: 'boolean',
|
||||
value: inventreeLoad('bom-export-pricing_data', false),
|
||||
}
|
||||
},
|
||||
onSubmit: function(fields, opts) {
|
||||
|
||||
// Extract values from the form
|
||||
var field_names = ['format', 'cascade', 'levels', 'parameter_data', 'stock_data', 'manufacturer_data', 'supplier_data'];
|
||||
var field_names = [
|
||||
'format', 'cascade', 'levels',
|
||||
'parameter_data',
|
||||
'stock_data',
|
||||
'manufacturer_data',
|
||||
'supplier_data',
|
||||
'pricing_data',
|
||||
];
|
||||
|
||||
var url = `/part/${part_id}/bom-download/?`;
|
||||
|
||||
@ -750,11 +763,6 @@ function loadBomTable(table, options={}) {
|
||||
ordering: 'name',
|
||||
};
|
||||
|
||||
// Do we show part pricing in the BOM table?
|
||||
var show_pricing = global_settings.PART_SHOW_PRICE_IN_BOM;
|
||||
|
||||
params.include_pricing = show_pricing == true;
|
||||
|
||||
if (options.part_detail) {
|
||||
params.part_detail = true;
|
||||
}
|
||||
@ -905,6 +913,7 @@ function loadBomTable(table, options={}) {
|
||||
title: '{% trans "Quantity" %}',
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
formatter: function(value, row) {
|
||||
var text = value;
|
||||
|
||||
@ -958,53 +967,6 @@ function loadBomTable(table, options={}) {
|
||||
}
|
||||
});
|
||||
|
||||
cols.push({
|
||||
field: 'available_stock',
|
||||
title: '{% trans "Available" %}',
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
|
||||
var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
|
||||
|
||||
// Calculate total "available" (unallocated) quantity
|
||||
var substitute_stock = row.available_substitute_stock || 0;
|
||||
var variant_stock = row.allow_variants ? (row.available_variant_stock || 0) : 0;
|
||||
|
||||
var available_stock = availableQuantity(row);
|
||||
|
||||
var text = `${available_stock}`;
|
||||
|
||||
if (row.sub_part_detail && row.sub_part_detail.units) {
|
||||
text += ` <small>${row.sub_part_detail.units}</small>`;
|
||||
}
|
||||
|
||||
if (available_stock <= 0) {
|
||||
text += `<span class='fas fa-times-circle icon-red float-right' title='{% trans "No Stock Available" %}'></span>`;
|
||||
} else {
|
||||
var extra = '';
|
||||
|
||||
if ((substitute_stock > 0) && (variant_stock > 0)) {
|
||||
extra = '{% trans "Includes variant and substitute stock" %}';
|
||||
} else if (variant_stock > 0) {
|
||||
extra = '{% trans "Includes variant stock" %}';
|
||||
} else if (substitute_stock > 0) {
|
||||
extra = '{% trans "Includes substitute stock" %}';
|
||||
}
|
||||
|
||||
if (extra) {
|
||||
text += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (row.on_order && row.on_order > 0) {
|
||||
text += `<span class='fas fa-shopping-cart float-right' title='{% trans "On Order" %}: ${row.on_order}'></span>`;
|
||||
}
|
||||
|
||||
return renderLink(text, url);
|
||||
}
|
||||
});
|
||||
|
||||
cols.push({
|
||||
field: 'substitutes',
|
||||
title: '{% trans "Substitutes" %}',
|
||||
@ -1065,34 +1027,137 @@ function loadBomTable(table, options={}) {
|
||||
}
|
||||
});
|
||||
|
||||
if (show_pricing) {
|
||||
cols.push({
|
||||
field: 'purchase_price_range',
|
||||
title: '{% trans "Purchase Price Range" %}',
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
});
|
||||
cols.push({
|
||||
field: 'pricing',
|
||||
title: '{% trans "Price Range" %}',
|
||||
sortable: true,
|
||||
sorter: function(valA, valB, rowA, rowB) {
|
||||
var a = rowA.pricing_min || rowA.pricing_max;
|
||||
var b = rowB.pricing_min || rowB.pricing_max;
|
||||
|
||||
cols.push({
|
||||
field: 'purchase_price_avg',
|
||||
title: '{% trans "Purchase Price Average" %}',
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
});
|
||||
if (a != null) {
|
||||
a = parseFloat(a) * rowA.quantity;
|
||||
}
|
||||
|
||||
cols.push({
|
||||
field: 'price_range',
|
||||
title: '{% trans "Supplier Cost" %}',
|
||||
sortable: true,
|
||||
formatter: function(value) {
|
||||
if (value) {
|
||||
return value;
|
||||
if (b != null) {
|
||||
b = parseFloat(b) * rowB.quantity;
|
||||
}
|
||||
|
||||
return (a > b) ? 1 : -1;
|
||||
},
|
||||
formatter: function(value, row) {
|
||||
|
||||
return formatPriceRange(
|
||||
row.pricing_min,
|
||||
row.pricing_max,
|
||||
{
|
||||
quantity: row.quantity
|
||||
}
|
||||
);
|
||||
},
|
||||
footerFormatter: function(data) {
|
||||
// Display overall price range the "footer" of the price_range column
|
||||
|
||||
var min_price = 0;
|
||||
var max_price = 0;
|
||||
|
||||
var any_pricing = false;
|
||||
var complete_pricing = true;
|
||||
|
||||
for (var idx = 0; idx < data.length; idx++) {
|
||||
|
||||
var row = data[idx];
|
||||
|
||||
// No pricing data available for this row
|
||||
if (row.pricing_min == null && row.pricing_max == null) {
|
||||
complete_pricing = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// At this point, we have at least *some* information
|
||||
any_pricing = true;
|
||||
|
||||
// Extract min/max values for this row
|
||||
var row_min = row.pricing_min || row.pricing_max;
|
||||
var row_max = row.pricing_max || row.pricing_min;
|
||||
|
||||
min_price += parseFloat(row_min) * row.quantity;
|
||||
max_price += parseFloat(row_max) * row.quantity;
|
||||
}
|
||||
|
||||
if (any_pricing) {
|
||||
var html = formatCurrency(min_price) + ' - ' + formatCurrency(max_price);
|
||||
|
||||
if (complete_pricing) {
|
||||
html += makeIconBadge(
|
||||
'fa-check-circle icon-green',
|
||||
'{% trans "BOM pricing is complete" %}',
|
||||
);
|
||||
} else {
|
||||
return `<span class='warning-msg'>{% trans 'No supplier pricing available' %}</span>`;
|
||||
html += makeIconBadge(
|
||||
'fa-exclamation-circle icon-yellow',
|
||||
'{% trans "BOM pricing is incomplete" %}',
|
||||
);
|
||||
}
|
||||
|
||||
return html;
|
||||
|
||||
} else {
|
||||
var html = '<em>{% trans "No pricing available" %}</em>';
|
||||
html += makeIconBadge('fa-times-circle icon-red');
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
cols.push({
|
||||
field: 'available_stock',
|
||||
title: '{% trans "Available" %}',
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
|
||||
var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
|
||||
|
||||
// Calculate total "available" (unallocated) quantity
|
||||
var substitute_stock = row.available_substitute_stock || 0;
|
||||
var variant_stock = row.allow_variants ? (row.available_variant_stock || 0) : 0;
|
||||
|
||||
var available_stock = availableQuantity(row);
|
||||
|
||||
var text = `${available_stock}`;
|
||||
|
||||
if (row.sub_part_detail && row.sub_part_detail.units) {
|
||||
text += ` <small>${row.sub_part_detail.units}</small>`;
|
||||
}
|
||||
|
||||
if (available_stock <= 0) {
|
||||
text += `<span class='fas fa-times-circle icon-red float-right' title='{% trans "No Stock Available" %}'></span>`;
|
||||
} else {
|
||||
var extra = '';
|
||||
|
||||
if ((substitute_stock > 0) && (variant_stock > 0)) {
|
||||
extra = '{% trans "Includes variant and substitute stock" %}';
|
||||
} else if (variant_stock > 0) {
|
||||
extra = '{% trans "Includes variant stock" %}';
|
||||
} else if (substitute_stock > 0) {
|
||||
extra = '{% trans "Includes substitute stock" %}';
|
||||
}
|
||||
|
||||
if (extra) {
|
||||
text += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (row.on_order && row.on_order > 0) {
|
||||
text += `<span class='fas fa-shopping-cart float-right' title='{% trans "On Order" %}: ${row.on_order}'></span>`;
|
||||
}
|
||||
|
||||
return renderLink(text, url);
|
||||
}
|
||||
});
|
||||
|
||||
cols.push(
|
||||
{
|
||||
@ -1216,7 +1281,6 @@ function loadBomTable(table, options={}) {
|
||||
{
|
||||
part: part_pk,
|
||||
sub_part_detail: true,
|
||||
include_pricing: show_pricing == true,
|
||||
},
|
||||
{
|
||||
success: function(response) {
|
||||
@ -1434,8 +1498,7 @@ function loadUsedInTable(table, part_id, options={}) {
|
||||
|
||||
params.uses = part_id;
|
||||
params.part_detail = true;
|
||||
params.sub_part_detail = true,
|
||||
params.show_pricing = global_settings.PART_SHOW_PRICE_IN_BOM;
|
||||
params.sub_part_detail = true;
|
||||
|
||||
var filters = {};
|
||||
|
||||
|
75
InvenTree/templates/js/translated/charts.js
Normal file
75
InvenTree/templates/js/translated/charts.js
Normal file
@ -0,0 +1,75 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
/* globals
|
||||
*/
|
||||
|
||||
/* exported
|
||||
loadBarChart,
|
||||
loadDoughnutChart,
|
||||
loadLineChart,
|
||||
randomColor,
|
||||
*/
|
||||
|
||||
|
||||
/* Generate a random color */
|
||||
function randomColor() {
|
||||
return '#' + (Math.random().toString(16) + '0000000').slice(2, 8);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Load a simple bar chart
|
||||
*/
|
||||
function loadBarChart(context, data) {
|
||||
return new Chart(context, {
|
||||
type: 'bar',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Load a simple doughnut chart
|
||||
*/
|
||||
function loadDoughnutChart(context, data) {
|
||||
return new Chart(context, {
|
||||
type: 'doughnut',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Load a simple line chart
|
||||
*/
|
||||
function loadLineChart(context, data) {
|
||||
return new Chart(context, {
|
||||
type: 'line',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {position: 'bottom'},
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
@ -22,6 +22,7 @@
|
||||
loadManufacturerPartTable,
|
||||
loadManufacturerPartParameterTable,
|
||||
loadSupplierPartTable,
|
||||
loadSupplierPriceBreakTable,
|
||||
*/
|
||||
|
||||
|
||||
@ -1092,3 +1093,97 @@ function loadSupplierPartTable(table, url, options) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Load a table of supplier price break data
|
||||
*/
|
||||
function loadSupplierPriceBreakTable(options={}) {
|
||||
|
||||
var table = options.table || $('#price-break-table');
|
||||
|
||||
// Setup button callbacks once table is loaded
|
||||
function setupCallbacks() {
|
||||
table.find('.button-price-break-delete').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`/api/company/price-break/${pk}/`, {
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Delete Price Break" %}',
|
||||
onSuccess: function() {
|
||||
table.bootstrapTable('refresh');
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
table.find('.button-price-break-edit').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`/api/company/price-break/${pk}/`, {
|
||||
fields: {
|
||||
quantity: {},
|
||||
price: {},
|
||||
price_currency: {},
|
||||
},
|
||||
title: '{% trans "Edit Price Break" %}',
|
||||
onSuccess: function() {
|
||||
table.bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setupFilterList('supplierpricebreak', table, '#filter-list-supplierpricebreak');
|
||||
|
||||
table.inventreeTable({
|
||||
name: 'buypricebreaks',
|
||||
url: '{% url "api-part-supplier-price-list" %}',
|
||||
queryParams: {
|
||||
part: options.part,
|
||||
},
|
||||
formatNoMatches: function() {
|
||||
return '{% trans "No price break information found" %}';
|
||||
},
|
||||
onPostBody: function() {
|
||||
setupCallbacks();
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
field: 'pk',
|
||||
title: 'ID',
|
||||
visible: false,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'quantity',
|
||||
title: '{% trans "Quantity" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'price',
|
||||
title: '{% trans "Price" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index) {
|
||||
return formatCurrency(value, {
|
||||
currency: row.price_currency
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'updated',
|
||||
title: '{% trans "Last updated" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
var html = renderDate(value);
|
||||
|
||||
html += `<div class='btn-group float-right' role='group'>`;
|
||||
html += makeIconButton('fa-edit icon-blue', 'button-price-break-edit', row.pk, '{% trans "Edit price break" %}');
|
||||
html += makeIconButton('fa-trash-alt icon-red', 'button-price-break-delete', row.pk, '{% trans "Delete price break" %}');
|
||||
html += `</div>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
},
|
||||
]
|
||||
});
|
||||
}
|
||||
|
@ -4,7 +4,9 @@
|
||||
blankImage,
|
||||
deleteButton,
|
||||
editButton,
|
||||
formatCurrency,
|
||||
formatDecimal,
|
||||
formatPriceRange,
|
||||
imageHoverIcon,
|
||||
makeIconBadge,
|
||||
makeIconButton,
|
||||
@ -38,6 +40,75 @@ function deleteButton(url, text='{% trans "Delete" %}') {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* format currency (money) value based on current settings
|
||||
*
|
||||
* Options:
|
||||
* - currency: Currency code (uses default value if none provided)
|
||||
* - locale: Locale specified (uses default value if none provided)
|
||||
* - digits: Maximum number of significant digits (default = 10)
|
||||
*/
|
||||
function formatCurrency(value, options={}) {
|
||||
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var digits = options.digits || global_settings.PRICING_DECIMAL_PLACES || 6;
|
||||
|
||||
// Strip out any trailing zeros, etc
|
||||
value = formatDecimal(value, digits);
|
||||
|
||||
// Extract default currency information
|
||||
var currency = options.currency || global_settings.INVENTREE_DEFAULT_CURRENCY || 'USD';
|
||||
|
||||
// Exctract locale information
|
||||
var locale = options.locale || navigator.language || 'en-US';
|
||||
|
||||
|
||||
var formatter = new Intl.NumberFormat(
|
||||
locale,
|
||||
{
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
maximumSignificantDigits: digits,
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(value);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Format a range of prices
|
||||
*/
|
||||
function formatPriceRange(price_min, price_max, options={}) {
|
||||
|
||||
var p_min = price_min || price_max;
|
||||
var p_max = price_max || price_min;
|
||||
|
||||
var quantity = options.quantity || 1;
|
||||
|
||||
if (p_min == null && p_max == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
p_min = parseFloat(p_min) * quantity;
|
||||
p_max = parseFloat(p_max) * quantity;
|
||||
|
||||
var output = '';
|
||||
|
||||
output += formatCurrency(p_min, options);
|
||||
|
||||
if (p_min != p_max) {
|
||||
output += ' - ';
|
||||
output += formatCurrency(p_max, options);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Ensure a string does not exceed a maximum length.
|
||||
* Useful for displaying long strings in tables,
|
||||
|
@ -798,30 +798,64 @@ function poLineItemFields(options={}) {
|
||||
// If the pack_size != 1, add a note to the field
|
||||
var pack_size = 1;
|
||||
var units = '';
|
||||
var supplier_part_id = value;
|
||||
var quantity = getFormFieldValue('quantity', {}, opts);
|
||||
|
||||
// Remove any existing note fields
|
||||
$(opts.modal).find('#info-pack-size').remove();
|
||||
|
||||
if (value != null) {
|
||||
inventreeGet(`/api/company/part/${value}/`,
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Request information about the particular supplier part
|
||||
inventreeGet(`/api/company/part/${value}/`,
|
||||
{
|
||||
part_detail: true,
|
||||
},
|
||||
{
|
||||
success: function(response) {
|
||||
// Extract information from the returned query
|
||||
pack_size = response.pack_size || 1;
|
||||
units = response.part_detail.units || '';
|
||||
},
|
||||
}
|
||||
).then(function() {
|
||||
// Update pack size information
|
||||
if (pack_size != 1) {
|
||||
var txt = `<span class='fas fa-info-circle icon-blue'></span> {% trans "Pack Quantity" %}: ${pack_size} ${units}`;
|
||||
$(opts.modal).find('#hint_id_quantity').after(`<div class='form-info-message' id='info-pack-size'>${txt}</div>`);
|
||||
}
|
||||
}).then(function() {
|
||||
// Update pricing data (if available)
|
||||
inventreeGet(
|
||||
'{% url "api-part-supplier-price-list" %}',
|
||||
{
|
||||
part_detail: true,
|
||||
part: supplier_part_id,
|
||||
ordering: 'quantity',
|
||||
},
|
||||
{
|
||||
success: function(response) {
|
||||
// Extract information from the returned query
|
||||
pack_size = response.pack_size || 1;
|
||||
units = response.part_detail.units || '';
|
||||
},
|
||||
}
|
||||
).then(function() {
|
||||
// Returned prices are in increasing order of quantity
|
||||
if (response.length > 0) {
|
||||
var idx = 0;
|
||||
|
||||
if (pack_size != 1) {
|
||||
var txt = `<span class='fas fa-info-circle icon-blue'></span> {% trans "Pack Quantity" %}: ${pack_size} ${units}`;
|
||||
$(opts.modal).find('#hint_id_quantity').after(`<div class='form-info-message' id='info-pack-size'>${txt}</div>`);
|
||||
for (var idx = 0; idx < response.length; idx++) {
|
||||
if (response[idx].quantity > quantity) {
|
||||
break;
|
||||
}
|
||||
|
||||
index = idx;
|
||||
}
|
||||
|
||||
// Update price and currency data in the form
|
||||
updateFieldValue('purchase_price', response[index].price, {}, opts);
|
||||
updateFieldValue('purchase_price_currency', response[index].price_currency, {}, opts);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
secondary: {
|
||||
method: 'POST',
|
||||
@ -2305,14 +2339,9 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
field: 'purchase_price',
|
||||
title: '{% trans "Unit Price" %}',
|
||||
formatter: function(value, row) {
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: row.purchase_price_currency
|
||||
}
|
||||
);
|
||||
return formatter.format(row.purchase_price);
|
||||
return formatCurrency(row.purchase_price, {
|
||||
currency: row.purchase_price_currency,
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2320,14 +2349,9 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
sortable: true,
|
||||
title: '{% trans "Total Price" %}',
|
||||
formatter: function(value, row) {
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: row.purchase_price_currency
|
||||
}
|
||||
);
|
||||
return formatter.format(row.purchase_price * row.quantity);
|
||||
return formatCurrency(row.purchase_price * row.quantity, {
|
||||
currency: row.purchase_price_currency
|
||||
});
|
||||
},
|
||||
footerFormatter: function(data) {
|
||||
var total = data.map(function(row) {
|
||||
@ -2338,15 +2362,9 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
|
||||
var currency = (data.slice(-1)[0] && data.slice(-1)[0].purchase_price_currency) || 'USD';
|
||||
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: currency
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(total);
|
||||
return formatCurrency(total, {
|
||||
currency: currency
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2508,15 +2526,9 @@ function loadPurchaseOrderExtraLineTable(table, options={}) {
|
||||
field: 'price',
|
||||
title: '{% trans "Unit Price" %}',
|
||||
formatter: function(value, row) {
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: row.price_currency
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(row.price);
|
||||
return formatCurrency(row.price, {
|
||||
currency: row.price_currency,
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2524,15 +2536,9 @@ function loadPurchaseOrderExtraLineTable(table, options={}) {
|
||||
sortable: true,
|
||||
title: '{% trans "Total Price" %}',
|
||||
formatter: function(value, row) {
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: row.price_currency
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(row.price * row.quantity);
|
||||
return formatCurrency(row.price * row.quantity, {
|
||||
currency: row.price_currency,
|
||||
});
|
||||
},
|
||||
footerFormatter: function(data) {
|
||||
var total = data.map(function(row) {
|
||||
@ -2543,15 +2549,9 @@ function loadPurchaseOrderExtraLineTable(table, options={}) {
|
||||
|
||||
var currency = (data.slice(-1)[0] && data.slice(-1)[0].price_currency) || 'USD';
|
||||
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: currency
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(total);
|
||||
return formatCurrency(total, {
|
||||
currency: currency,
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
@ -3732,7 +3732,7 @@ function reloadTotal() {
|
||||
{},
|
||||
{
|
||||
success: function(data) {
|
||||
$(TotalPriceRef).html(data.total_price_string);
|
||||
$(TotalPriceRef).html(formatCurrency(data.price, {currency: data.price_currency}));
|
||||
}
|
||||
}
|
||||
);
|
||||
@ -3851,15 +3851,9 @@ function loadSalesOrderLineItemTable(table, options={}) {
|
||||
field: 'sale_price',
|
||||
title: '{% trans "Unit Price" %}',
|
||||
formatter: function(value, row) {
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: row.sale_price_currency
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(row.sale_price);
|
||||
return formatCurrency(row.sale_price, {
|
||||
currency: row.sale_price_currency
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -3867,15 +3861,9 @@ function loadSalesOrderLineItemTable(table, options={}) {
|
||||
sortable: true,
|
||||
title: '{% trans "Total Price" %}',
|
||||
formatter: function(value, row) {
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: row.sale_price_currency
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(row.sale_price * row.quantity);
|
||||
return formatCurrency(row.sale_price * row.quantity, {
|
||||
currency: row.sale_price_currency,
|
||||
});
|
||||
},
|
||||
footerFormatter: function(data) {
|
||||
var total = data.map(function(row) {
|
||||
@ -3886,15 +3874,9 @@ function loadSalesOrderLineItemTable(table, options={}) {
|
||||
|
||||
var currency = (data.slice(-1)[0] && data.slice(-1)[0].sale_price_currency) || 'USD';
|
||||
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: currency
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(total);
|
||||
return formatCurrency(total, {
|
||||
currency: currency,
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -4360,15 +4342,9 @@ function loadSalesOrderExtraLineTable(table, options={}) {
|
||||
field: 'price',
|
||||
title: '{% trans "Unit Price" %}',
|
||||
formatter: function(value, row) {
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: row.price_currency
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(row.price);
|
||||
return formatCurrency(row.price, {
|
||||
currency: row.price_currency,
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -4376,15 +4352,9 @@ function loadSalesOrderExtraLineTable(table, options={}) {
|
||||
sortable: true,
|
||||
title: '{% trans "Total Price" %}',
|
||||
formatter: function(value, row) {
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: row.price_currency
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(row.price * row.quantity);
|
||||
return formatCurrency(row.price * row.quantity, {
|
||||
currency: row.price_currency,
|
||||
});
|
||||
},
|
||||
footerFormatter: function(data) {
|
||||
var total = data.map(function(row) {
|
||||
@ -4395,15 +4365,9 @@ function loadSalesOrderExtraLineTable(table, options={}) {
|
||||
|
||||
var currency = (data.slice(-1)[0] && data.slice(-1)[0].price_currency) || 'USD';
|
||||
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: currency
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(total);
|
||||
return formatCurrency(total, {
|
||||
currency: currency,
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
|
@ -26,8 +26,6 @@
|
||||
duplicatePart,
|
||||
editCategory,
|
||||
editPart,
|
||||
initPriceBreakSet,
|
||||
loadBomChart,
|
||||
loadParametricPartTable,
|
||||
loadPartCategoryTable,
|
||||
loadPartParameterTable,
|
||||
@ -37,9 +35,7 @@
|
||||
loadPartSchedulingChart,
|
||||
loadPartVariantTable,
|
||||
loadRelatedPartsTable,
|
||||
loadSellPricingChart,
|
||||
loadSimplePartTable,
|
||||
loadStockPricingChart,
|
||||
partStockLabel,
|
||||
toggleStar,
|
||||
validateBom,
|
||||
@ -781,6 +777,16 @@ function loadPartVariantTable(table, partId, options={}) {
|
||||
|
||||
return renderLink(text, `/part/${row.pk}/?display=part-stock`);
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'price_range',
|
||||
title: '{% trans "Price Range" %}',
|
||||
formatter: function(value, row) {
|
||||
return formatPriceRange(
|
||||
row.pricing_min,
|
||||
row.pricing_max,
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@ -813,6 +819,9 @@ function loadPartVariantTable(table, partId, options={}) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Load a "simplified" part table without filtering
|
||||
*/
|
||||
function loadSimplePartTable(table, url, options={}) {
|
||||
|
||||
options.disableFilters = true;
|
||||
@ -1121,15 +1130,9 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
|
||||
title: '{% trans "Price" %}',
|
||||
switchable: true,
|
||||
formatter: function(value, row) {
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: row.purchase_price_currency,
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(row.purchase_price);
|
||||
return formatCurrency(row.purchase_price, {
|
||||
currency: row.purchase_price_currency,
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -1392,19 +1395,19 @@ function partGridTile(part) {
|
||||
}
|
||||
|
||||
|
||||
/* Load part listing data into specified table.
|
||||
*
|
||||
* Args:
|
||||
* - table: HTML reference to the table
|
||||
* - url: Base URL for API query
|
||||
* - options: object containing following (optional) fields
|
||||
* checkbox: Show the checkbox column
|
||||
* query: extra query params for API request
|
||||
* buttons: If provided, link buttons to selection status of this table
|
||||
* disableFilters: If true, disable custom filters
|
||||
* actions: Provide a callback function to construct an "actions" column
|
||||
*/
|
||||
function loadPartTable(table, url, options={}) {
|
||||
/* Load part listing data into specified table.
|
||||
*
|
||||
* Args:
|
||||
* - table: HTML reference to the table
|
||||
* - url: Base URL for API query
|
||||
* - options: object containing following (optional) fields
|
||||
* checkbox: Show the checkbox column
|
||||
* query: extra query params for API request
|
||||
* buttons: If provided, link buttons to selection status of this table
|
||||
* disableFilters: If true, disable custom filters
|
||||
* actions: Provide a callback function to construct an "actions" column
|
||||
*/
|
||||
|
||||
// Ensure category detail is included
|
||||
options.params['category_detail'] = true;
|
||||
@ -1444,21 +1447,11 @@ function loadPartTable(table, url, options={}) {
|
||||
});
|
||||
}
|
||||
|
||||
col = {
|
||||
field: 'IPN',
|
||||
title: '{% trans "IPN" %}',
|
||||
};
|
||||
|
||||
if (!options.params.ordering) {
|
||||
col['sortable'] = true;
|
||||
}
|
||||
|
||||
columns.push(col);
|
||||
|
||||
col = {
|
||||
columns.push({
|
||||
field: 'name',
|
||||
title: '{% trans "Part" %}',
|
||||
switchable: false,
|
||||
sortable: !options.params.ordering,
|
||||
formatter: function(value, row) {
|
||||
|
||||
var name = shortenString(row.full_name);
|
||||
@ -1469,13 +1462,13 @@ function loadPartTable(table, url, options={}) {
|
||||
|
||||
return withTitle(display, row.full_name);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
if (!options.params.ordering) {
|
||||
col['sortable'] = true;
|
||||
}
|
||||
|
||||
columns.push(col);
|
||||
columns.push({
|
||||
field: 'IPN',
|
||||
title: '{% trans "IPN" %}',
|
||||
sortable: !options.params.ordering
|
||||
});
|
||||
|
||||
columns.push({
|
||||
field: 'description',
|
||||
@ -1582,6 +1575,19 @@ function loadPartTable(table, url, options={}) {
|
||||
|
||||
columns.push(col);
|
||||
|
||||
// Pricing information
|
||||
columns.push({
|
||||
field: 'pricing_min',
|
||||
sortable: false,
|
||||
title: '{% trans "Price Range" %}',
|
||||
formatter: function(value, row) {
|
||||
return formatPriceRange(
|
||||
row.pricing_min,
|
||||
row.pricing_max
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
columns.push({
|
||||
field: 'link',
|
||||
title: '{% trans "Link" %}',
|
||||
@ -1838,7 +1844,7 @@ function loadPartCategoryTable(table, options) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
|
||||
setupFilterList(filterKey, table, filterListElement);
|
||||
setupFilterList(filterKey, table, filterListElement, {download: true});
|
||||
|
||||
// Function to request sub-category items
|
||||
function requestSubItems(parent_pk) {
|
||||
@ -2176,173 +2182,6 @@ function loadPartTestTemplateTable(table, options) {
|
||||
}
|
||||
|
||||
|
||||
function loadPriceBreakTable(table, options) {
|
||||
/*
|
||||
* Load PriceBreak table.
|
||||
*/
|
||||
|
||||
var name = options.name || 'pricebreak';
|
||||
var human_name = options.human_name || 'price break';
|
||||
var linkedGraph = options.linkedGraph || null;
|
||||
var chart = null;
|
||||
|
||||
table.inventreeTable({
|
||||
name: name,
|
||||
method: 'get',
|
||||
formatNoMatches: function() {
|
||||
return `{% trans "No ${human_name} information found" %}`;
|
||||
},
|
||||
queryParams: {
|
||||
part: options.part
|
||||
},
|
||||
url: options.url,
|
||||
onLoadSuccess: function(tableData) {
|
||||
if (linkedGraph) {
|
||||
// sort array
|
||||
tableData = tableData.sort((a, b) => (a.quantity - b.quantity));
|
||||
|
||||
// split up for graph definition
|
||||
var graphLabels = Array.from(tableData, (x) => (x.quantity));
|
||||
var graphData = Array.from(tableData, (x) => (x.price));
|
||||
|
||||
// destroy chart if exists
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
chart = loadLineChart(linkedGraph,
|
||||
{
|
||||
labels: graphLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: '{% trans "Unit Price" %}',
|
||||
data: graphData,
|
||||
backgroundColor: 'rgba(255, 206, 86, 0.2)',
|
||||
borderColor: 'rgb(255, 206, 86)',
|
||||
stepped: true,
|
||||
fill: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
field: 'pk',
|
||||
title: 'ID',
|
||||
visible: false,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'quantity',
|
||||
title: '{% trans "Quantity" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'price',
|
||||
title: '{% trans "Price" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
var html = value;
|
||||
|
||||
html += `<div class='btn-group float-right' role='group'>`;
|
||||
|
||||
html += makeIconButton('fa-edit icon-blue', `button-${name}-edit`, row.pk, `{% trans "Edit ${human_name}" %}`);
|
||||
html += makeIconButton('fa-trash-alt icon-red', `button-${name}-delete`, row.pk, `{% trans "Delete ${human_name}" %}`);
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
},
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
function loadLineChart(context, data) {
|
||||
return new Chart(context, {
|
||||
type: 'line',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {position: 'bottom'},
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initPriceBreakSet(table, options) {
|
||||
|
||||
var part_id = options.part_id;
|
||||
var pb_human_name = options.pb_human_name;
|
||||
var pb_url_slug = options.pb_url_slug;
|
||||
var pb_url = options.pb_url;
|
||||
var pb_new_btn = options.pb_new_btn;
|
||||
var pb_new_url = options.pb_new_url;
|
||||
|
||||
var linkedGraph = options.linkedGraph || null;
|
||||
|
||||
loadPriceBreakTable(
|
||||
table,
|
||||
{
|
||||
name: pb_url_slug,
|
||||
human_name: pb_human_name,
|
||||
url: pb_url,
|
||||
linkedGraph: linkedGraph,
|
||||
part: part_id,
|
||||
}
|
||||
);
|
||||
|
||||
function reloadPriceBreakTable() {
|
||||
table.bootstrapTable('refresh');
|
||||
}
|
||||
|
||||
pb_new_btn.click(function() {
|
||||
|
||||
constructForm(pb_new_url, {
|
||||
fields: {
|
||||
part: {
|
||||
hidden: true,
|
||||
value: part_id,
|
||||
},
|
||||
quantity: {},
|
||||
price: {},
|
||||
price_currency: {},
|
||||
},
|
||||
method: 'POST',
|
||||
title: '{% trans "Add Price Break" %}',
|
||||
onSuccess: reloadPriceBreakTable,
|
||||
});
|
||||
});
|
||||
|
||||
table.on('click', `.button-${pb_url_slug}-delete`, function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`${pb_url}${pk}/`, {
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Delete Price Break" %}',
|
||||
onSuccess: reloadPriceBreakTable,
|
||||
});
|
||||
});
|
||||
|
||||
table.on('click', `.button-${pb_url_slug}-edit`, function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`${pb_url}${pk}/`, {
|
||||
fields: {
|
||||
quantity: {},
|
||||
price: {},
|
||||
price_currency: {},
|
||||
},
|
||||
title: '{% trans "Edit Price Break" %}',
|
||||
onSuccess: reloadPriceBreakTable,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Load a chart which displays projected scheduling information for a particular part.
|
||||
* This takes into account:
|
||||
@ -2719,115 +2558,3 @@ function loadPartSchedulingChart(canvas_id, part_id) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function loadStockPricingChart(context, data) {
|
||||
return new Chart(context, {
|
||||
type: 'bar',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {legend: {position: 'bottom'}},
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
grid: {display: false},
|
||||
title: {
|
||||
display: true,
|
||||
text: '{% trans "Single Price" %}'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
grid: {display: false},
|
||||
titel: {
|
||||
display: true,
|
||||
text: '{% trans "Quantity" %}',
|
||||
position: 'right'
|
||||
}
|
||||
},
|
||||
y2: {
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
grid: {display: false},
|
||||
title: {
|
||||
display: true,
|
||||
text: '{% trans "Single Price Difference" %}'
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function loadBomChart(context, data) {
|
||||
return new Chart(context, {
|
||||
type: 'doughnut',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
},
|
||||
scales: {
|
||||
xAxes: [
|
||||
{
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
autoSkip: false,
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function loadSellPricingChart(context, data) {
|
||||
return new Chart(context, {
|
||||
type: 'line',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: '{% trans "Unit Price" %}',
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
titel: {
|
||||
display: true,
|
||||
text: '{% trans "Quantity" %}',
|
||||
position: 'right'
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
791
InvenTree/templates/js/translated/pricing.js
Normal file
791
InvenTree/templates/js/translated/pricing.js
Normal file
@ -0,0 +1,791 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
/* Functions for retrieving and displaying pricing data */
|
||||
|
||||
/* globals
|
||||
*/
|
||||
|
||||
/* exported
|
||||
loadBomPricingChart,
|
||||
loadPartSupplierPricingTable,
|
||||
initPriceBreakSet,
|
||||
loadPriceBreakTable,
|
||||
loadPurchasePriceHistoryTable,
|
||||
loadSalesPriceHistoryTable,
|
||||
loadVariantPricingChart,
|
||||
*/
|
||||
|
||||
|
||||
/*
|
||||
* Load BOM pricing chart
|
||||
*/
|
||||
function loadBomPricingChart(options={}) {
|
||||
|
||||
var part = options.part;
|
||||
|
||||
if (!part) {
|
||||
console.error('No part provided to loadPurchasePriceHistoryTable');
|
||||
return;
|
||||
}
|
||||
|
||||
var table = options.table || $('#bom-pricing-table');
|
||||
var chartElement = options.table || $('#bom-pricing-chart');
|
||||
|
||||
var chart = null;
|
||||
|
||||
options.params = options.params || {};
|
||||
|
||||
options.params.part = part;
|
||||
options.params.sub_part_detail = true;
|
||||
options.params.ordering = 'name';
|
||||
options.params.has_pricing = true;
|
||||
|
||||
table.inventreeTable({
|
||||
url: '{% url "api-bom-list" %}',
|
||||
name: 'bompricingtable',
|
||||
queryParams: options.params,
|
||||
original: options.params,
|
||||
paginationVAlign: 'bottom',
|
||||
pageSize: 10,
|
||||
search: false,
|
||||
showColumns: false,
|
||||
formatNoMatches: function() {
|
||||
return '{% trans "No BOM data available" %}';
|
||||
},
|
||||
onLoadSuccess: function(data) {
|
||||
// Construct BOM pricing chart
|
||||
// Note here that we use stacked bars to denote "min" and "max" costs
|
||||
|
||||
// Ignore any entries without pricing information
|
||||
data = data.filter((x) => x.pricing_min != null || x.pricing_max != null);
|
||||
|
||||
// Sort in decreasing order of "maximum price"
|
||||
data = data.sort(function(a, b) {
|
||||
var pa = parseFloat(a.quantity * (a.pricing_max || a.pricing_min));
|
||||
var pb = parseFloat(b.quantity * (b.pricing_max || b.pricing_min));
|
||||
|
||||
return pb - pa;
|
||||
});
|
||||
|
||||
var graphLabels = Array.from(data, (x) => x.sub_part_detail.name);
|
||||
var minValues = Array.from(data, (x) => x.quantity * (x.pricing_min || x.pricing_max));
|
||||
var maxValues = Array.from(data, (x) => x.quantity * (x.pricing_max || x.pricing_min));
|
||||
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
|
||||
// Generate colors
|
||||
var colors = Array.from(data, (x) => randomColor());
|
||||
|
||||
chart = loadDoughnutChart(chartElement, {
|
||||
labels: graphLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: '{% trans "Maximum Price" %}',
|
||||
data: maxValues,
|
||||
backgroundColor: colors,
|
||||
},
|
||||
{
|
||||
label: '{% trans "Minimum Price" %}',
|
||||
data: minValues,
|
||||
backgroundColor: colors,
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
field: 'sub_part',
|
||||
title: '{% trans "Part" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
var url = `/part/${row.sub_part}/`;
|
||||
|
||||
var part = row.sub_part_detail;
|
||||
|
||||
return imageHoverIcon(part.thumbnail) + renderLink(part.full_name, url);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'quantity',
|
||||
title: '{% trans "Quantity" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'reference',
|
||||
title: '{% trans "Reference" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'pricing',
|
||||
title: '{% trans "Price Range" %}',
|
||||
sortable: false,
|
||||
formatter: function(value, row) {
|
||||
var min_price = row.pricing_min;
|
||||
var max_price = row.pricing_max;
|
||||
|
||||
if (min_price == null && max_price == null) {
|
||||
// No pricing information available at all
|
||||
return null;
|
||||
}
|
||||
|
||||
// If pricing is the same, return single value
|
||||
if (min_price == max_price) {
|
||||
return formatCurrency(min_price * row.quantity);
|
||||
}
|
||||
|
||||
var output = '';
|
||||
|
||||
if (min_price != null) {
|
||||
output += formatCurrency(min_price * row.quantity);
|
||||
|
||||
if (max_price != null) {
|
||||
output += ' - ';
|
||||
}
|
||||
}
|
||||
|
||||
if (max_price != null) {
|
||||
output += formatCurrency(max_price * row.quantity);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Load a table displaying complete supplier pricing information for a given part
|
||||
*/
|
||||
function loadPartSupplierPricingTable(options={}) {
|
||||
|
||||
var part = options.part;
|
||||
|
||||
if (!part) {
|
||||
console.error('No part provided to loadPurchasePriceHistoryTable');
|
||||
return;
|
||||
}
|
||||
|
||||
var table = options.table || $('#part-supplier-pricing-table');
|
||||
var chartElement = options.chart || $('#part-supplier-pricing-chart');
|
||||
|
||||
var chart = null;
|
||||
|
||||
options.params = options.params || {};
|
||||
|
||||
options.params.base_part = part;
|
||||
options.params.supplier_detail = true;
|
||||
options.params.part_detail = true;
|
||||
|
||||
table.inventreeTable({
|
||||
url: '{% url "api-part-supplier-price-list" %}',
|
||||
name: 'partsupplierprice',
|
||||
queryParams: options.params,
|
||||
original: options.params,
|
||||
paginationVAlign: 'bottom',
|
||||
pageSize: 10,
|
||||
pageList: null,
|
||||
search: false,
|
||||
showColumns: false,
|
||||
formatNoMatches: function() {
|
||||
return '{% trans "No supplier pricing data available" %}';
|
||||
},
|
||||
onLoadSuccess: function(data) {
|
||||
// Update supplier pricing chart
|
||||
|
||||
// Only allow values with pricing information
|
||||
data = data.filter((x) => x.price != null);
|
||||
|
||||
// Sort in increasing order of quantity
|
||||
data = data.sort((a, b) => (a.quantity - b.quantity));
|
||||
|
||||
var graphLabels = Array.from(data, (x) => (`${x.part_detail.SKU} - {% trans "Quantity" %} ${x.quantity}`));
|
||||
var graphValues = Array.from(data, (x) => (x.price / x.part_detail.pack_size));
|
||||
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
|
||||
chart = loadBarChart(chartElement, {
|
||||
labels: graphLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: '{% trans "Supplier Pricing" %}',
|
||||
data: graphValues,
|
||||
backgroundColor: 'rgba(255, 206, 86, 0.2)',
|
||||
borderColor: 'rgb(255, 206, 86)',
|
||||
stepped: true,
|
||||
fill: true,
|
||||
}
|
||||
]
|
||||
});
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
field: 'supplier',
|
||||
title: '{% trans "Supplier" %}',
|
||||
formatter: function(value, row) {
|
||||
var html = '';
|
||||
|
||||
html += imageHoverIcon(row.supplier_detail.image);
|
||||
html += renderLink(row.supplier_detail.name, `/company/${row.supplier}/`);
|
||||
|
||||
return html;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'sku',
|
||||
title: '{% trans "SKU" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
return renderLink(
|
||||
row.part_detail.SKU,
|
||||
`/supplier-part/${row.part}/`
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'quantity',
|
||||
title: '{% trans "Quantity" %}',
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'price',
|
||||
title: '{% trans "Unit Price" %}',
|
||||
formatter: function(value, row) {
|
||||
|
||||
if (row.price == null) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
// Convert to unit pricing
|
||||
var unit_price = row.price / row.part_detail.pack_size;
|
||||
|
||||
var html = formatCurrency(unit_price, {
|
||||
currency: row.price_currency
|
||||
});
|
||||
|
||||
if (row.updated != null) {
|
||||
html += `<span class='badge badge-right rounded-pill bg-dark'>${renderDate(row.updated)}</span>`;
|
||||
}
|
||||
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Load PriceBreak table.
|
||||
*/
|
||||
function loadPriceBreakTable(table, options={}) {
|
||||
|
||||
var name = options.name || 'pricebreak';
|
||||
var human_name = options.human_name || 'price break';
|
||||
var linkedGraph = options.linkedGraph || null;
|
||||
var chart = null;
|
||||
|
||||
table.inventreeTable({
|
||||
name: name,
|
||||
search: false,
|
||||
showColumns: false,
|
||||
paginationVAlign: 'bottom',
|
||||
pageSize: 10,
|
||||
method: 'get',
|
||||
formatNoMatches: function() {
|
||||
return `{% trans "No price break data available" %}`;
|
||||
},
|
||||
queryParams: {
|
||||
part: options.part
|
||||
},
|
||||
url: options.url,
|
||||
onLoadSuccess: function(tableData) {
|
||||
if (linkedGraph) {
|
||||
// sort array
|
||||
tableData = tableData.sort((a, b) => (a.quantity - b.quantity));
|
||||
|
||||
// split up for graph definition
|
||||
var graphLabels = Array.from(tableData, (x) => (x.quantity));
|
||||
var graphData = Array.from(tableData, (x) => (x.price));
|
||||
|
||||
// Destroy chart if it already exists
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
|
||||
chart = loadBarChart(linkedGraph, {
|
||||
labels: graphLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: '{% trans "Unit Price" %}',
|
||||
data: graphData,
|
||||
backgroundColor: 'rgba(255, 206, 86, 0.2)',
|
||||
borderColor: 'rgb(255, 206, 86)',
|
||||
stepped: true,
|
||||
fill: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
field: 'pk',
|
||||
title: 'ID',
|
||||
visible: false,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'quantity',
|
||||
title: '{% trans "Quantity" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'price',
|
||||
title: '{% trans "Price" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
var html = formatCurrency(value, {currency: row.price_currency});
|
||||
|
||||
html += `<div class='btn-group float-right' role='group'>`;
|
||||
|
||||
html += makeIconButton('fa-edit icon-blue', `button-${name}-edit`, row.pk, `{% trans "Edit ${human_name}" %}`);
|
||||
html += makeIconButton('fa-trash-alt icon-red', `button-${name}-delete`, row.pk, `{% trans "Delete ${human_name}" %}`);
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
},
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function initPriceBreakSet(table, options) {
|
||||
|
||||
var part_id = options.part_id;
|
||||
var pb_human_name = options.pb_human_name;
|
||||
var pb_url_slug = options.pb_url_slug;
|
||||
var pb_url = options.pb_url;
|
||||
var pb_new_btn = options.pb_new_btn;
|
||||
var pb_new_url = options.pb_new_url;
|
||||
|
||||
var linkedGraph = options.linkedGraph || null;
|
||||
|
||||
loadPriceBreakTable(
|
||||
table,
|
||||
{
|
||||
name: pb_url_slug,
|
||||
human_name: pb_human_name,
|
||||
url: pb_url,
|
||||
linkedGraph: linkedGraph,
|
||||
part: part_id,
|
||||
}
|
||||
);
|
||||
|
||||
function reloadPriceBreakTable() {
|
||||
table.bootstrapTable('refresh');
|
||||
}
|
||||
|
||||
pb_new_btn.click(function() {
|
||||
|
||||
constructForm(pb_new_url, {
|
||||
fields: {
|
||||
part: {
|
||||
hidden: true,
|
||||
value: part_id,
|
||||
},
|
||||
quantity: {},
|
||||
price: {},
|
||||
price_currency: {},
|
||||
},
|
||||
method: 'POST',
|
||||
title: '{% trans "Add Price Break" %}',
|
||||
onSuccess: reloadPriceBreakTable,
|
||||
});
|
||||
});
|
||||
|
||||
table.on('click', `.button-${pb_url_slug}-delete`, function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`${pb_url}${pk}/`, {
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Delete Price Break" %}',
|
||||
onSuccess: reloadPriceBreakTable,
|
||||
});
|
||||
});
|
||||
|
||||
table.on('click', `.button-${pb_url_slug}-edit`, function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`${pb_url}${pk}/`, {
|
||||
fields: {
|
||||
quantity: {},
|
||||
price: {},
|
||||
price_currency: {},
|
||||
},
|
||||
title: '{% trans "Edit Price Break" %}',
|
||||
onSuccess: reloadPriceBreakTable,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Load purchase price history for the given part
|
||||
*/
|
||||
function loadPurchasePriceHistoryTable(options={}) {
|
||||
|
||||
var part = options.part;
|
||||
|
||||
if (!part) {
|
||||
console.error('No part provided to loadPurchasePriceHistoryTable');
|
||||
return;
|
||||
}
|
||||
|
||||
var table = options.table || $('#part-purchase-history-table');
|
||||
var chartElement = options.chart || $('#part-purchase-history-chart');
|
||||
|
||||
var chart = null;
|
||||
|
||||
options.params = options.params || {};
|
||||
|
||||
options.params.base_part = part;
|
||||
options.params.part_detail = true;
|
||||
options.params.order_detail = true;
|
||||
options.params.has_pricing = true;
|
||||
|
||||
// Purchase order must be 'COMPLETE'
|
||||
options.params.order_status = {{ PurchaseOrderStatus.COMPLETE }};
|
||||
|
||||
table.inventreeTable({
|
||||
url: '{% url "api-po-line-list" %}',
|
||||
name: 'partpurchasehistory',
|
||||
queryParams: options.params,
|
||||
original: options.params,
|
||||
paginationVAlign: 'bottom',
|
||||
pageSize: 10,
|
||||
search: false,
|
||||
showColumns: false,
|
||||
formatNoMatches: function() {
|
||||
return '{% trans "No purchase history data available" %}';
|
||||
},
|
||||
onLoadSuccess: function(data) {
|
||||
// Update purchase price history chart
|
||||
|
||||
// Only allow values with pricing information
|
||||
data = data.filter((x) => x.purchase_price != null);
|
||||
|
||||
// Sort in increasing date order
|
||||
data = data.sort((a, b) => (a.order_detail.complete_date - b.order_detail.complete_date));
|
||||
|
||||
var graphLabels = Array.from(data, (x) => (`${x.order_detail.reference} - ${x.order_detail.complete_date}`));
|
||||
var graphValues = Array.from(data, (x) => (x.purchase_price / x.supplier_part_detail.pack_size));
|
||||
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
|
||||
chart = loadBarChart(chartElement, {
|
||||
labels: graphLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: '{% trans "Purchase Price History" %}',
|
||||
data: graphValues,
|
||||
backgroundColor: 'rgba(255, 206, 86, 0.2)',
|
||||
borderColor: 'rgb(255, 206, 86)',
|
||||
stepped: true,
|
||||
fill: true,
|
||||
}
|
||||
]
|
||||
});
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
field: 'order',
|
||||
title: '{% trans "Purchase Order" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
var order = row.order_detail;
|
||||
|
||||
if (!order) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
var html = '';
|
||||
var supplier = row.supplier_part_detail.supplier_detail;
|
||||
|
||||
html += imageHoverIcon(supplier.thumbnail || supplier.image);
|
||||
html += renderLink(order.reference, `/order/purchase-order/${order.pk}/`);
|
||||
html += ' - ';
|
||||
html += renderLink(supplier.name, `/company/${supplier.pk}/`);
|
||||
|
||||
return html;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'order_detail.complete_date',
|
||||
title: '{% trans "Date" %}',
|
||||
sortable: true,
|
||||
formatter: function(value) {
|
||||
return renderDate(value);
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'purchase_price',
|
||||
title: '{% trans "Unit Price" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
|
||||
if (row.purchase_price == null) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return formatCurrency(row.purchase_price / row.supplier_part_detail.pack_size, {
|
||||
currency: row.purchase_price_currency
|
||||
});
|
||||
}
|
||||
},
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Load sales price history for the given part
|
||||
*/
|
||||
function loadSalesPriceHistoryTable(options={}) {
|
||||
|
||||
var part = options.part;
|
||||
|
||||
if (!part) {
|
||||
console.error('No part provided to loadPurchasePriceHistoryTable');
|
||||
return;
|
||||
}
|
||||
|
||||
var table = options.table || $('#part-sales-history-table');
|
||||
var chartElement = options.chart || $('#part-sales-history-chart');
|
||||
|
||||
var chart = null;
|
||||
|
||||
options.params = options.params || {};
|
||||
|
||||
options.params.part = part;
|
||||
options.params.order_detail = true;
|
||||
options.params.customer_detail = true;
|
||||
|
||||
// Only return results which have pricing information
|
||||
options.params.has_pricing = true;
|
||||
|
||||
// Sales order must be 'SHIPPED'
|
||||
options.params.order_status = {{ SalesOrderStatus.SHIPPED }};
|
||||
|
||||
table.inventreeTable({
|
||||
url: '{% url "api-so-line-list" %}',
|
||||
name: 'partsaleshistory',
|
||||
queryParams: options.params,
|
||||
original: options.params,
|
||||
paginationVAlign: 'bottom',
|
||||
pageSize: 10,
|
||||
search: false,
|
||||
showColumns: false,
|
||||
formatNoMatches: function() {
|
||||
return '{% trans "No sales history data available" %}';
|
||||
},
|
||||
onLoadSuccess: function(data) {
|
||||
// Update sales price history chart
|
||||
|
||||
// Ignore any orders which have not shipped
|
||||
data = data.filter((x) => x.order_detail.shipment_date != null);
|
||||
|
||||
// Sort in increasing date order
|
||||
data = data.sort((a, b) => (a.order_detail.shipment_date - b.order_detail.shipment_date));
|
||||
|
||||
var graphLabels = Array.from(data, (x) => x.order_detail.shipment_date);
|
||||
var graphValues = Array.from(data, (x) => x.sale_price);
|
||||
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
|
||||
chart = loadBarChart(chartElement, {
|
||||
labels: graphLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: '{% trans "Sale Price History" %}',
|
||||
data: graphValues,
|
||||
backgroundColor: 'rgba(255, 206, 86, 0.2)',
|
||||
borderColor: 'rgb(255, 206, 86)',
|
||||
stepped: true,
|
||||
fill: true,
|
||||
}
|
||||
]
|
||||
});
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
field: 'order',
|
||||
title: '{% trans "Sales Order" %}',
|
||||
formatter: function(value, row) {
|
||||
var order = row.order_detail;
|
||||
var customer = row.customer_detail;
|
||||
|
||||
if (!order) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
var html = '';
|
||||
|
||||
html += imageHoverIcon(customer.thumbnail || customer.image);
|
||||
html += renderLink(order.reference, `/order/sales-order/${order.pk}/`);
|
||||
html += ' - ';
|
||||
html += renderLink(customer.name, `/company/${customer.pk}/`);
|
||||
|
||||
return html;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'shipment_date',
|
||||
title: '{% trans "Date" %}',
|
||||
formatter: function(value, row) {
|
||||
return renderDate(row.order_detail.shipment_date);
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'sale_price',
|
||||
title: '{% trans "Sale Price" %}',
|
||||
formatter: function(value, row) {
|
||||
return formatCurrency(value, {
|
||||
currency: row.sale_price_currency
|
||||
});
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Load chart and table for part variant pricing
|
||||
*/
|
||||
function loadVariantPricingChart(options={}) {
|
||||
|
||||
var part = options.part;
|
||||
|
||||
if (!part) {
|
||||
console.error('No part provided to loadPurchasePriceHistoryTable');
|
||||
return;
|
||||
}
|
||||
|
||||
var table = options.table || $('#variant-pricing-table');
|
||||
var chartElement = options.chart || $('#variant-pricing-chart');
|
||||
|
||||
var chart = null;
|
||||
|
||||
options.params = options.params || {};
|
||||
|
||||
options.params.ancestor = part;
|
||||
|
||||
table.inventreeTable({
|
||||
url: '{% url "api-part-list" %}',
|
||||
name: 'variantpricingtable',
|
||||
queryParams: options.params,
|
||||
original: options.params,
|
||||
paginationVAlign: 'bottom',
|
||||
pageSize: 10,
|
||||
search: false,
|
||||
showColumns: false,
|
||||
formatNoMatches: function() {
|
||||
return '{% trans "No variant data available" %}';
|
||||
},
|
||||
onLoadSuccess: function(data) {
|
||||
// Construct variant pricing chart
|
||||
|
||||
data = data.filter((x) => x.pricing_min != null || x.pricing_max != null);
|
||||
|
||||
var graphLabels = Array.from(data, (x) => x.full_name);
|
||||
var minValues = Array.from(data, (x) => x.pricing_min || x.pricing_max);
|
||||
var maxValues = Array.from(data, (x) => x.pricing_max || x.pricing_min);
|
||||
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
|
||||
chart = loadBarChart(chartElement, {
|
||||
labels: graphLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: '{% trans "Minimum Price" %}',
|
||||
data: minValues,
|
||||
backgroundColor: 'rgba(200, 250, 200, 0.75)',
|
||||
borderColor: 'rgba(200, 250, 200)',
|
||||
stepped: true,
|
||||
fill: true,
|
||||
},
|
||||
{
|
||||
label: '{% trans "Maximum Price" %}',
|
||||
data: maxValues,
|
||||
backgroundColor: 'rgba(250, 220, 220, 0.75)',
|
||||
borderColor: 'rgba(250, 220, 220)',
|
||||
stepped: true,
|
||||
fill: true,
|
||||
}
|
||||
]
|
||||
});
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
field: 'part',
|
||||
title: '{% trans "Variant Part" %}',
|
||||
formatter: function(value, row) {
|
||||
var name = shortenString(row.full_name);
|
||||
var display = imageHoverIcon(row.thumbnail) + renderLink(name, `/part/${row.pk}/`);
|
||||
return withTitle(display, row.full_name);
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'pricing',
|
||||
title: '{% trans "Price Range" %}',
|
||||
formatter: function(value, row) {
|
||||
var min_price = row.pricing_min;
|
||||
var max_price = row.pricing_max;
|
||||
|
||||
if (min_price == null && max_price == null) {
|
||||
// No pricing information available at all
|
||||
return null;
|
||||
}
|
||||
|
||||
// If pricing is the same, return single value
|
||||
if (min_price == max_price) {
|
||||
return formatCurrency(min_price);
|
||||
}
|
||||
|
||||
var output = '';
|
||||
|
||||
if (min_price != null) {
|
||||
output += formatCurrency(min_price);
|
||||
|
||||
if (max_price != null) {
|
||||
output += ' - ';
|
||||
}
|
||||
}
|
||||
|
||||
if (max_price != null) {
|
||||
output += formatCurrency(max_price);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
@ -1980,17 +1980,16 @@ function loadStockTable(table, options) {
|
||||
|
||||
columns.push(col);
|
||||
|
||||
col = {
|
||||
field: 'purchase_price_string',
|
||||
columns.push({
|
||||
field: 'purchase_price',
|
||||
title: '{% trans "Purchase Price" %}',
|
||||
};
|
||||
|
||||
if (!options.params.ordering) {
|
||||
col.sortable = true;
|
||||
col.sortName = 'purchase_price';
|
||||
}
|
||||
|
||||
columns.push(col);
|
||||
sortable: false,
|
||||
formatter: function(value, row) {
|
||||
return formatCurrency(value, {
|
||||
currency: row.purchase_price_currency,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
columns.push({
|
||||
field: 'packaging',
|
||||
@ -2268,7 +2267,7 @@ function loadStockLocationTable(table, options) {
|
||||
original[k] = params[k];
|
||||
}
|
||||
|
||||
setupFilterList(filterKey, table, filterListElement);
|
||||
setupFilterList(filterKey, table, filterListElement, {download: true});
|
||||
|
||||
for (var key in params) {
|
||||
filters[key] = params[key];
|
||||
|
@ -87,6 +87,10 @@ function getAvailableTableFilters(tableKey) {
|
||||
type: 'bool',
|
||||
title: '{% trans "Consumable" %}',
|
||||
},
|
||||
has_pricing: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Has Pricing" %}',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -498,7 +502,11 @@ function getAvailableTableFilters(tableKey) {
|
||||
virtual: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Virtual" %}',
|
||||
}
|
||||
},
|
||||
has_pricing: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Has Pricing" %}',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
8
InvenTree/templates/price_data.html
Normal file
8
InvenTree/templates/price_data.html
Normal file
@ -0,0 +1,8 @@
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
|
||||
{% if price %}
|
||||
{% render_currency price %}
|
||||
{% else %}
|
||||
<em>{% trans "No data" %}</em>
|
||||
{% endif %}
|
@ -85,6 +85,7 @@ class RuleSet(models.Model):
|
||||
],
|
||||
'part': [
|
||||
'part_part',
|
||||
'part_partpricing',
|
||||
'part_bomitem',
|
||||
'part_bomitemsubstitute',
|
||||
'part_partattachment',
|
||||
|
@ -60,8 +60,6 @@ def check_prohibited_tags(data):
|
||||
|
||||
err_count = 0
|
||||
|
||||
has_trans = False
|
||||
|
||||
for idx, line in enumerate(data):
|
||||
|
||||
for tag in re.findall(pattern, line):
|
||||
@ -70,13 +68,6 @@ def check_prohibited_tags(data):
|
||||
print(f" > Line {idx+1} contains prohibited template tag '{tag}'")
|
||||
err_count += 1
|
||||
|
||||
if tag == 'trans':
|
||||
has_trans = True
|
||||
|
||||
if not has_trans:
|
||||
print(" > file is missing 'trans' tags")
|
||||
err_count += 1
|
||||
|
||||
return err_count
|
||||
|
||||
|
||||
|
2
tasks.py
2
tasks.py
@ -543,7 +543,7 @@ def test(c, database=None):
|
||||
manage(c, 'test', pty=True)
|
||||
|
||||
|
||||
@task(help={'dev': 'Set up development enviroment at the end'})
|
||||
@task(help={'dev': 'Set up development environment at the end'})
|
||||
def setup_test(c, ignore_update=False, dev=False, path="inventree-demo-dataset"):
|
||||
"""Setup a testing enviroment."""
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user