Part pricing cache (#3710)

* Create new model for storing Part pricing data

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

* Define function for accessing pricing information for a specific part

* Adds admin site support for new PartPricing model

* Specify role for PartPricing model

* Allow blank values for PartPricing model fields

* Add some TODO entries

* Update migration files to sync with latest master

* Expose API endpoint for viewing part pricing information

* Update migration file

* Improvements:

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

* Fix (and test) for API serializer

* Including min/max pricing data in part serializer

* Bump API version

* Add pricing overview information in part table

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

* Overhaul of BOM API

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

* Filter BOM items by "has pricing"

* Part API endpoint can be filtered by price range

* Updpated API version notes

* Improvements for price caching calculations

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

* Button to manually refresh pricing

* Improve rendering of price-break table

* Update supplier part pricing table

* Updated js functions

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

* Updates for task offloading

* HTML tweaks

* Implement calculation of historical purchase cost

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

* Improvements for pricing tab rendering

* Refactor of pricing page

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

* Refactor BOM pricing chart / table

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

* Update page anchors

* Improvements for BOM pricing table display

* Refactoring sales data tables

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

* Add extra fields to PartPricing model:

- sale_price_min
- sale_price_max
- sale_history_min
- sale_history_max

* Calculate and cache sale price data

* Update part pricing when PurchaseOrder is completed

* Update part pricing when sales order is completed

* Signals for updating part pricing cache

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

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

* Update part pricing whenever a supplier price break is updated

* Remove has_complete_bom_pricing method

* Export min/max pricing data in BOM file

* Fix pricing data in BOM export

- Calculate total line cost
- Use more than two digits

* Add pricing information to part export

Also some improvements to part exporting

* Allow download of part category table

* Allow export of stock location data to file

* Improved exporting of StockItem data

* Add cached variant pricing data

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

* Remove outdated "PART_SHOW_PRICE_HISTORY" setting

* Adds scheduled background task to periodically update part pricing

* Internal prices can optionally override other pricing

* Update js file checks

* Update price breaks to use 6 decimal places

* Fix for InvenTreeMoneySerializer class

- Allow 6 decimal places through the API

* Update for supplier price break table

* javascript linting fix

* Further js fixes

* Unit test updates

* Improve rendering of currency in templates

- Do not artificially limit to 2 decimal places

* Unit test fixes

* Add pricing information to part "details" tab

* Tweak for money formatting

* Enable sort-by-price in BOM table

* More unit test tweaks

* Update BOM exporting

* Fixes for background worker process

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

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

* Force background processing of heartbeat task when server is started

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

* Adjust strategy for preventing recursion

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

* Adds helper function for rendering a range of prices

* Include variant cost in calculations

* Fixes for "has_pricing" API filters

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

* Bug fix for BOM API filter

* Include BOM quantity in BOM pricing chart

* Small tweaks to pricing tab

* Prevent caching when looking up settings in background worker

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

* Fixes for /part/pricing/ detail API endpoint

* Update pricing tab

- Consistent naming

* Unit test fixes

* Prevent pricing updates when loading test fixtures

* Fix for Part.pricing

* Updates for "check_missing_pricing"

* Change to pie chart for BOM pricing

* Unit test fix

* Updates

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

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

* remove old unused setting

* Consolidate settings tabs for pricing and currencies

* Fix CI after changing settings page

* Fix rendering for "Supplier Pricing"

- Take unit pricing / pack size into account

* Extra filtering / ordering options for the SupplierPriceBreak API endpoint

* Fix for purchase price history graph

- Use unit pricing (take pack size into account)

* JS fixes
This commit is contained in:
Oliver 2022-11-14 15:58:22 +11:00 committed by GitHub
parent 8ceb1af3c3
commit 06266b48af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
69 changed files with 3747 additions and 1629 deletions

View File

@ -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

View File

@ -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):

View File

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

View File

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

View File

@ -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

View File

@ -72,7 +72,7 @@ class ViewTests(InvenTreeTestCase):
'server',
'login',
'barcodes',
'currencies',
'pricing',
'parts',
'stock',
]

View File

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

View File

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

View File

@ -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.

View File

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

View File

@ -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."""

View File

@ -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'),
),
]

View File

@ -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()

View File

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

View File

@ -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");
}
}
);
});

View File

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

View File

@ -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."""

View 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'),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
"""

View File

@ -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:

View File

@ -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

View File

@ -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

View File

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

View File

@ -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"""

View File

@ -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'),
),
]

View File

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

View File

@ -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',
]
"""

View File

@ -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(

View File

@ -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 %}

View File

@ -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>

View 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 %}

View File

@ -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" %}

View File

@ -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 %}

View File

@ -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>

View File

@ -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 = {};

View 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'},
}
}
});
}

View File

@ -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;
}
},
]
});
}

View File

@ -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,

View File

@ -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,
});
}
}
];

View File

@ -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'
}
},
},
}
});
}

View 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;
}
}
]
});
}

View File

@ -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];

View File

@ -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" %}',
},
};
}

View File

@ -0,0 +1,8 @@
{% load inventree_extras %}
{% load i18n %}
{% if price %}
{% render_currency price %}
{% else %}
<em>{% trans "No data" %}</em>
{% endif %}

View File

@ -85,6 +85,7 @@ class RuleSet(models.Model):
],
'part': [
'part_part',
'part_partpricing',
'part_bomitem',
'part_bomitemsubstitute',
'part_partattachment',

View File

@ -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

View File

@ -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."""