diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index e1f3a2514e..d48999189b 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index 6e2c020373..3e81933b10 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -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): diff --git a/InvenTree/InvenTree/fields.py b/InvenTree/InvenTree/fields.py index b4df86e9e2..e93d9a795e 100644 --- a/InvenTree/InvenTree/fields.py +++ b/InvenTree/InvenTree/fields.py @@ -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) diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index caa81f1e23..e60e3495a5 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -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) diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index d2a54577e8..fc81ab8ba8 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -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 diff --git a/InvenTree/InvenTree/test_views.py b/InvenTree/InvenTree/test_views.py index bab93e69eb..f3630229b7 100644 --- a/InvenTree/InvenTree/test_views.py +++ b/InvenTree/InvenTree/test_views.py @@ -72,7 +72,7 @@ class ViewTests(InvenTreeTestCase): 'server', 'login', 'barcodes', - 'currencies', + 'pricing', 'parts', 'stock', ] diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index b9b3cec1a2..50455d7601 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -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'), diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index d4e50c596c..a3c5a112c8 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -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'), diff --git a/InvenTree/common/notifications.py b/InvenTree/common/notifications.py index 0928fad6d0..af9b6aa21c 100644 --- a/InvenTree/common/notifications.py +++ b/InvenTree/common/notifications.py @@ -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. diff --git a/InvenTree/company/admin.py b/InvenTree/company/admin.py index 11e9a2720e..da5a3c3735 100644 --- a/InvenTree/company/admin.py +++ b/InvenTree/company/admin.py @@ -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',) diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py index 3b194a9946..a5d376bb42 100644 --- a/InvenTree/company/api.py +++ b/InvenTree/company/api.py @@ -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.""" diff --git a/InvenTree/company/migrations/0051_alter_supplierpricebreak_price.py b/InvenTree/company/migrations/0051_alter_supplierpricebreak_price.py new file mode 100644 index 0000000000..9786ff618b --- /dev/null +++ b/InvenTree/company/migrations/0051_alter_supplierpricebreak_price.py @@ -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'), + ), + ] diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 5a9a4e1e01..86463228f8 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -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() diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index 109d8adb4a..11e69120ad 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -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', ] diff --git a/InvenTree/company/templates/company/supplier_part.html b/InvenTree/company/templates/company/supplier_part.html index 9a058c2d4e..32c5f425aa 100644 --- a/InvenTree/company/templates/company/supplier_part.html +++ b/InvenTree/company/templates/company/supplier_part.html @@ -252,6 +252,9 @@ src="{% static 'img/blank_image.png' %}"
+
+ {% include "filter_list.html" with id='supplierpricebreak' %} +
@@ -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 += `
` - - 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 += `
`; - - 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"); + } } ); }); diff --git a/InvenTree/company/test_api.py b/InvenTree/company/test_api.py index 98db9c58f2..6f3e9e5bf2 100644 --- a/InvenTree/company/test_api.py +++ b/InvenTree/company/test_api.py @@ -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') diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index e15aa0dcd0..3bf9035f13 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -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.""" diff --git a/InvenTree/order/migrations/0076_auto_20221111_0153.py b/InvenTree/order/migrations/0076_auto_20221111_0153.py new file mode 100644 index 0000000000..c00d950b5d --- /dev/null +++ b/InvenTree/order/migrations/0076_auto_20221111_0153.py @@ -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'), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 1645817b20..02614ffde7 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -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'), diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 33d4c7f535..f240f29de1 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -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', ] diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index bf4ae571f5..ff4ae2bb67 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -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) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 049b6dd9e4..0c65203574 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -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'), ])), diff --git a/InvenTree/part/apps.py b/InvenTree/part/apps.py index 9a8ce076b5..298ff486cd 100644 --- a/InvenTree/part/apps.py +++ b/InvenTree/part/apps.py @@ -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() diff --git a/InvenTree/part/bom.py b/InvenTree/part/bom.py index 2e1018060d..c8642bc97b 100644 --- a/InvenTree/part/bom.py +++ b/InvenTree/part/bom.py @@ -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: diff --git a/InvenTree/part/migrations/0089_auto_20221112_0128.py b/InvenTree/part/migrations/0089_auto_20221112_0128.py new file mode 100644 index 0000000000..ed84289568 --- /dev/null +++ b/InvenTree/part/migrations/0089_auto_20221112_0128.py @@ -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')), + ], + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index a2d7e5bc26..2aaf771158 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -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. diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 73e82d868c..6f0707df46 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -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 diff --git a/InvenTree/part/tasks.py b/InvenTree/part/tasks.py index fc932239a9..4579efef0f 100644 --- a/InvenTree/part/tasks.py +++ b/InvenTree/part/tasks.py @@ -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() diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 4f9c4edd12..f72a2cc173 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -131,11 +131,9 @@ -{% if part.purchaseable or part.salable %}
{% include "part/prices.html" %}
-{% endif %}
@@ -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'); diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 8749292c7e..e94b561302 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -323,6 +323,21 @@ {% endif %} + {% with part.pricing as pricing %} + {% if pricing.is_valid %} +
+ + + + + {% endif %} + {% endwith %} {% with part.get_latest_serial_number as sn %} {% if part.trackable and sn %} diff --git a/InvenTree/part/templates/part/part_pricing.html b/InvenTree/part/templates/part/part_pricing.html index b6f45fa8e9..52c7e44525 100644 --- a/InvenTree/part/templates/part/part_pricing.html +++ b/InvenTree/part/templates/part/part_pricing.html @@ -76,14 +76,6 @@ {% endif %} {% endif %} - {% if not part.has_complete_bom_pricing %} - - - - {% endif %} - {% if min_total_bom_price or min_total_bom_purchase_price %} {% else %} diff --git a/InvenTree/part/templates/part/part_sidebar.html b/InvenTree/part/templates/part/part_sidebar.html index e8763fb973..a7eb993ab8 100644 --- a/InvenTree/part/templates/part/part_sidebar.html +++ b/InvenTree/part/templates/part/part_sidebar.html @@ -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" %} diff --git a/InvenTree/part/templates/part/prices.html b/InvenTree/part/templates/part/prices.html index 7ea93d6fdd..e34b2d3f09 100644 --- a/InvenTree/part/templates/part/prices.html +++ b/InvenTree/part/templates/part/prices.html @@ -5,252 +5,299 @@ {% settings_value "PART_INTERNAL_PRICE" as show_internal_price %} -{% if show_price_history %} +
-

{% trans "Pricing Information" %}

+
+

{% trans "Pricing Overview" %}

+ {% include "spacer.html" %} +
+ +
+
- -{% default_currency as currency %}
- -
- -
-

{% trans "Pricing ranges" %}

+ {% with part.pricing as pricing %} + {% if pricing.is_valid %} + +
+ {% trans "Last Updated" %}: {% render_date pricing.updated %} +
+
+
{% trans "Price Range" %} + {% if pricing.overall_min == pricing.overall_max %} + {% render_currency pricing.overall_max %} + {% else %} + {% render_currency pricing.overall_min %} - {% render_currency pricing.overall_max %} + {% endif %} +
- {% trans 'Note: BOM pricing is incomplete for this part' %} -
- {% if part.supplier_count > 0 %} - {% if min_total_buy_price %} + + - - - - + + + + - {% if quantity > 1 %} + + - - - - + + + + + + {% if part.purchaseable %} + + + + + + + + + + + {% endif %} - {% else %} + {% if part.assembly %} - - - {% endif %} - {% endif %} - - {% if part.assembly and part.bom_count > 0 %} - {% if min_total_bom_price %} - - - - - - - {% if quantity > 1 %} - - - - - - - {% endif %} - {% endif %} - - {% if min_total_bom_purchase_price %} - - - - - - - {% if quantity > 1 %} - - - - - + + + {% endif %} - {% endif %} - - {% if not part.has_complete_bom_pricing %} + {% if part.is_template %} - + + + + - {% endif %} - - {% if min_total_bom_price or min_total_bom_purchase_price %} - {% else %} + {% endif %} - + + + + - {% endif %} - {% endif %} - - {% if show_internal_price and roles.sales_order.view %} - {% if total_internal_part_price %} - - - - - - - - - - - {% endif %} - {% endif %} - - {% if total_part_price %} - - - - - - - - - - - {% endif %} + +
{% trans 'Supplier Pricing' %} - - - {% trans 'Unit Cost' %}Min: {% include "price.html" with price=min_unit_buy_price %}Max: {% include "price.html" with price=max_unit_buy_price %}{% trans "Price Category" %}{% trans "Minimum" %}{% trans "Maximum" %}
{% trans 'Total Cost' %}Min: {% include "price.html" with price=min_total_buy_price %}Max: {% include "price.html" with price=max_total_buy_price %} + {% if show_internal_price and roles.sales_order.view %} + + + + {% endif %} + + {% trans "Internal Pricing" %} + {% include "price_data.html" with price=pricing.internal_cost_min %}{% include "price_data.html" with price=pricing.internal_cost_max %}
+ {% if roles.purchase_order.view %} + + + + {% endif %} + + {% trans "Purchase History" %} + {% include "price_data.html" with price=pricing.purchase_cost_min %}{% include "price_data.html" with price=pricing.purchase_cost_max %}
+ {% if roles.purchase_order.view %} + + + + {% endif %} + + {% trans "Supplier Pricing" %} + {% include "price_data.html" with price=pricing.supplier_price_min %}{% include "price_data.html" with price=pricing.supplier_price_max %}
- {% trans 'No supplier pricing available' %} + + {% if part.has_bom %} + + + + {% endif %}
{% trans 'BOM Pricing' %} - - {% trans 'Unit Cost' %}Min: {% include "price.html" with price=min_unit_bom_price %}Max: {% include "price.html" with price=max_unit_bom_price %}
{% trans 'Total Cost' %}Min: {% include "price.html" with price=min_total_bom_price %}Max: {% include "price.html" with price=max_total_bom_price %}
{% trans 'Unit Purchase Price' %}Min: {% include "price.html" with price=min_unit_bom_purchase_price %}Max: {% include "price.html" with price=max_unit_bom_purchase_price %}
{% trans 'Total Purchase Price' %}Min: {% include "price.html" with price=min_total_bom_purchase_price %}Max: {% include "price.html" with price=max_total_bom_purchase_price %} + {% trans "BOM Pricing" %} + {% include "price_data.html" with price=pricing.bom_cost_min %}{% include "price_data.html" with price=pricing.bom_cost_max %}
- {% trans 'Note: BOM pricing is incomplete for this part' %} - {% trans "Variant Pricing" %}{% include "price_data.html" with price=pricing.variant_cost_min %}{% include "price_data.html" with price=pricing.variant_cost_max %}
- {% trans 'No BOM pricing available' %} - + {% trans "Overall Pricing" %} + {% include "price_data.html" with price=pricing.overall_min %}{% include "price_data.html" with price=pricing.overall_max %}
{% trans 'Internal Price' %}{% trans 'Unit Cost' %}{% include "price.html" with price=unit_internal_part_price %}
{% trans 'Total Cost' %}{% include "price.html" with price=total_internal_part_price %}
{% trans 'Sale Price' %} - - - {% trans 'Unit Cost' %}{% include "price.html" with price=unit_part_price %}
{% trans 'Total Cost' %}{% include "price.html" with price=total_part_price %}
+
+
+ {% if part.salable and roles.sales_order.view %} + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Price Category" %}{% trans "Minimum" %}{% trans "Maximum" %}
+ + + + + {% trans "Sale Price" %} + + {% include "price_data.html" with price=pricing.sale_price_min %} + + {% include "price_data.html" with price=pricing.sale_price_max %} +
+ + + + + {% trans "Sale History" %} + + {% include "price_data.html" with price=pricing.sale_history_min %} + + {% include "price_data.html" with price=pricing.sale_history_max %} +
- - {% if min_unit_buy_price or min_unit_bom_price or min_unit_bom_purchase_price %} {% else %} -
- {% trans 'No pricing information is available for this part.' %} -
+
+ {% trans "Sale price data is not available for this part" %} +
{% endif %}
- -
-

{% trans "Calculation parameters" %}

-
- {% csrf_token %} - {{ form|crispy }} - -
-
- - -{% endif %} - -{% if part.purchaseable and roles.purchase_order.view %} - -
-

{% trans "Supplier Cost" %} - -

-
- -
-
-
-

{% trans "Suppliers" %}

-
-
-
-

{% trans "Manufacturers" %}

-
-
+ {% else %} +
+ {% trans "Price range data is not available for this part." %}
+ {% endif %} + {% endwith %}
-{% if show_price_history %} - -
-

{% trans "Purchase Price" %} - -

-
-
-

{% trans 'Stock Pricing' %} - -

- {% if price_history|length > 0 %} -
- -
- {% else %} -
- {% trans 'No stock pricing history is available for this part.' %} -
- {% endif %} -
-{% endif %} -{% endif %} - {% if show_internal_price and roles.sales_order.view %}
-

{% trans "Internal Cost" %} - -

+
+

{% trans "Internal Pricing" %} + +

+ {% include "spacer.html" %} +
+ +
+
-
+
-
-
- -
+
- +
{% endif %} -{% if part.has_bom and roles.sales_order.view %} - +{% if part.purchaseable and roles.purchase_order.view %} +
-

{% trans "BOM Cost" %} +

+ {% trans "Purchase History" %} + +

+
+
+
+
+
+ +
+
+
+ +
+
+
+
+ + +
+

+ {% trans "Supplier Pricing" %}

-
+
-
-
- - {% if part.bom_count > 0 %} -
-

{% trans 'BOM Pricing' %}

-
- +
+
- {% endif %} +
+ +
+
+
+
+{% endif %} + + +{% if part.assembly and part.has_bom %} + +
+

{% trans "BOM Pricing" %} + +

+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+{% endif %} + +{% if part.is_template %} + +
+

+ {% trans "Variant Pricing" %} + +

+
+
+
+
+
+ +
+
+
+ +
+
{% endif %} @@ -258,50 +305,52 @@ {% if part.salable and roles.sales_order.view %}
-

{% trans "Sale Cost" %} +
+

{% trans "Sale Pricing" %} + +

+ {% include "spacer.html" %} +
+ +
+
+

+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ + +
+
+

{% trans "Sale History" %}

-
+
- +
-
-
- -
- - +
+
- -{% if show_price_history %} - -
-

{% trans "Sale Price" %} - -

-
- -
- {% if sale_history|length > 0 %} -
- -
- {% else %} -
- {% trans 'No sale pice history available for this part.' %} -
- {% endif %} -
-{% endif %} {% endif %} diff --git a/InvenTree/part/templates/part/pricing_javascript.html b/InvenTree/part/templates/part/pricing_javascript.html new file mode 100644 index 0000000000..7385d60b7c --- /dev/null +++ b/InvenTree/part/templates/part/pricing_javascript.html @@ -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 %} diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py index 6116acd327..7ee58181d9 100644 --- a/InvenTree/part/templatetags/inventree_extras.py +++ b/InvenTree/part/templatetags/inventree_extras.py @@ -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() diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index a029406761..9b5cbb0649 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -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...""" diff --git a/InvenTree/part/test_bom_export.py b/InvenTree/part/test_bom_export.py index ed93c0f54d..051f56b9ac 100644 --- a/InvenTree/part/test_bom_export.py +++ b/InvenTree/part/test_bom_export.py @@ -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.""" diff --git a/InvenTree/part/test_category.py b/InvenTree/part/test_category.py index c02aa1b6bd..7aa94f5535 100644 --- a/InvenTree/part/test_category.py +++ b/InvenTree/part/test_category.py @@ -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 diff --git a/InvenTree/part/test_pricing.py b/InvenTree/part/test_pricing.py new file mode 100644 index 0000000000..fd974ef64f --- /dev/null +++ b/InvenTree/part/test_pricing.py @@ -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')) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 876d54e1c3..1e3f2fbd22 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -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): diff --git a/InvenTree/plugin/base/action/test_action.py b/InvenTree/plugin/base/action/test_action.py index ead7e8f259..d343af677a 100644 --- a/InvenTree/plugin/base/action/test_action.py +++ b/InvenTree/plugin/base/action/test_action.py @@ -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 """ diff --git a/InvenTree/plugin/base/event/events.py b/InvenTree/plugin/base/event/events.py index 4b826d8aaa..61bdb6ae91 100644 --- a/InvenTree/plugin/base/event/events.py +++ b/InvenTree/plugin/base/event/events.py @@ -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: diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index 48357a2fe5..d8a96709d3 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -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 diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py index 76ddc46b68..471a2b0f09 100644 --- a/InvenTree/plugin/test_plugin.py +++ b/InvenTree/plugin/test_plugin.py @@ -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 diff --git a/InvenTree/stock/admin.py b/InvenTree/stock/admin.py index 270281ae3e..7f16d2d5c8 100644 --- a/InvenTree/stock/admin.py +++ b/InvenTree/stock/admin.py @@ -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', ] diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index e2a9604ec4..e5de1f54f5 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -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""" diff --git a/InvenTree/stock/migrations/0089_alter_stockitem_purchase_price.py b/InvenTree/stock/migrations/0089_alter_stockitem_purchase_price.py new file mode 100644 index 0000000000..d1fe79c6bd --- /dev/null +++ b/InvenTree/stock/migrations/0089_alter_stockitem_purchase_price.py @@ -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'), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 7c513bb694..b83a070fff 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -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'), diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index a4929be591..d319d387cc 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -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', ] """ diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 1d846e2725..06cdbd04aa 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -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( diff --git a/InvenTree/templates/InvenTree/settings/currencies.html b/InvenTree/templates/InvenTree/settings/currencies.html deleted file mode 100644 index 5f49087497..0000000000 --- a/InvenTree/templates/InvenTree/settings/currencies.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends "panel.html" %} -{% load i18n %} -{% load inventree_extras %} - -{% block label %}currencies{% endblock %} - -{% block heading %} -{% trans "Currency Settings" %} -{% endblock %} - -{% block content %} - - - - {% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-globe" %} - - - - - - - - - - - {% for rate in rates %} - - - - - - - - {% endfor %} - - - - - - -
{% trans "Base Currency" %}{{ base_currency }}
{% trans "Exchange Rates" %}
{{ rate.value }}{{ rate.currency }}
- {% trans "Last Update" %} - - {% if rates_updated %} - {{ rates_updated }} - {% else %} - {% trans "Never" %} - {% endif %} -
-
- {% csrf_token %} - -
-
-
- -{% endblock %} diff --git a/InvenTree/templates/InvenTree/settings/part.html b/InvenTree/templates/InvenTree/settings/part.html index aea734d5a4..543d868253 100644 --- a/InvenTree/templates/InvenTree/settings/part.html +++ b/InvenTree/templates/InvenTree/settings/part.html @@ -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" %} @@ -34,9 +31,6 @@ {% include "InvenTree/settings/setting.html" with key="PART_COPY_TESTS" %} {% include "InvenTree/settings/setting.html" with key="PART_CATEGORY_PARAMETERS" %} - {% 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="PART_CATEGORY_DEFAULT_ICON" icon="fa-icons" %} diff --git a/InvenTree/templates/InvenTree/settings/pricing.html b/InvenTree/templates/InvenTree/settings/pricing.html new file mode 100644 index 0000000000..8fa740a487 --- /dev/null +++ b/InvenTree/templates/InvenTree/settings/pricing.html @@ -0,0 +1,74 @@ +{% extends "panel.html" %} +{% load i18n %} + +{% block label %}pricing{% endblock %} + +{% block heading %} +{% trans "Pricing Settings" %} +{% endblock %} + +{% block panel_content %} +
+ + + {% 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' %} + +
+
+ +
+
+

{% trans "Currency Settings" %}

+ {% include "spacer.html" %} +
+
+
+ {% csrf_token %} + +
+
+
+
+
+
+ {% if rates_updated %} +
+ {% trans "Last Update" %} - {{ rates_updated }} +
+{% else %} +
+ {% trans "Last Update" %} - {% trans "Never" %} +
+{% endif %} + + + + {% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-globe" %} + + + + + + + + + + + + + {% for rate in rates %} + + + + + + + + {% endfor %} + +
{% trans "Base Currency" %}{{ base_currency }}
{% trans "Exchange Rates" %}{% trans "Currency" %}{% trans "Rate" %}
{{ rate.currency }}{{ rate.value }}
+
+{% endblock panel_content %} diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html index 79862291ae..7e553ad0a1 100644 --- a/InvenTree/templates/InvenTree/settings/settings.html +++ b/InvenTree/templates/InvenTree/settings/settings.html @@ -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" %} diff --git a/InvenTree/templates/InvenTree/settings/sidebar.html b/InvenTree/templates/InvenTree/settings/sidebar.html index bc9a4f441e..57070f463c 100644 --- a/InvenTree/templates/InvenTree/settings/sidebar.html +++ b/InvenTree/templates/InvenTree/settings/sidebar.html @@ -32,8 +32,8 @@ {% include "sidebar_item.html" with label='login' text=text icon="fa-fingerprint" %} {% trans "Barcode Support" as text %} {% include "sidebar_item.html" with label='barcodes' text=text icon="fa-qrcode" %} -{% trans "Currencies" as text %} -{% include "sidebar_item.html" with label='currencies' text=text icon="fa-dollar-sign" %} +{% trans "Pricing" as text %} +{% include "sidebar_item.html" with label='pricing' text=text icon="fa-dollar-sign" %} {% trans "Label Printing" as text %} {% include "sidebar_item.html" with label='labels' text=text icon='fa-tag' %} {% trans "Reporting" as text %} diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html index ee22e16c13..e2dcdaa768 100644 --- a/InvenTree/templates/base.html +++ b/InvenTree/templates/base.html @@ -154,6 +154,7 @@ + @@ -167,6 +168,7 @@ + diff --git a/InvenTree/templates/js/translated/bom.js b/InvenTree/templates/js/translated/bom.js index 1fe0746396..4bd0504709 100644 --- a/InvenTree/templates/js/translated/bom.js +++ b/InvenTree/templates/js/translated/bom.js @@ -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 += ` ${row.sub_part_detail.units}`; - } - - if (available_stock <= 0) { - text += ``; - } 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 += ``; - } - } - - if (row.on_order && row.on_order > 0) { - text += ``; - } - - 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 `{% trans 'No supplier pricing available' %}`; + html += makeIconBadge( + 'fa-exclamation-circle icon-yellow', + '{% trans "BOM pricing is incomplete" %}', + ); + } + + return html; + + } else { + var html = '{% trans "No pricing available" %}'; + 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 += ` ${row.sub_part_detail.units}`; + } + + if (available_stock <= 0) { + text += ``; + } 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 += ``; } } - }); - } + + if (row.on_order && row.on_order > 0) { + text += ``; + } + + 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 = {}; diff --git a/InvenTree/templates/js/translated/charts.js b/InvenTree/templates/js/translated/charts.js new file mode 100644 index 0000000000..9166cf0d13 --- /dev/null +++ b/InvenTree/templates/js/translated/charts.js @@ -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'}, + } + } + }); +} diff --git a/InvenTree/templates/js/translated/company.js b/InvenTree/templates/js/translated/company.js index d9eeddf60e..ebdaf4bbe1 100644 --- a/InvenTree/templates/js/translated/company.js +++ b/InvenTree/templates/js/translated/company.js @@ -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 += `
`; + 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 += `
`; + + return html; + } + }, + ] + }); +} diff --git a/InvenTree/templates/js/translated/helpers.js b/InvenTree/templates/js/translated/helpers.js index c32e01b311..eac2774671 100644 --- a/InvenTree/templates/js/translated/helpers.js +++ b/InvenTree/templates/js/translated/helpers.js @@ -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, diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 1db00c9697..4e0ca3e06b 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -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 = ` {% trans "Pack Quantity" %}: ${pack_size} ${units}`; + $(opts.modal).find('#hint_id_quantity').after(`
${txt}
`); + } + }).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 = ` {% trans "Pack Quantity" %}: ${pack_size} ${units}`; - $(opts.modal).find('#hint_id_quantity').after(`
${txt}
`); + 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, + }); } } ]; diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 3d111041b4..8af3b0bc04 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -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 += `
`; - - 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 += `
`; - - 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' - } - }, - }, - } - }); -} diff --git a/InvenTree/templates/js/translated/pricing.js b/InvenTree/templates/js/translated/pricing.js new file mode 100644 index 0000000000..66f9ed7c8c --- /dev/null +++ b/InvenTree/templates/js/translated/pricing.js @@ -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 += `${renderDate(row.updated)}`; + } + + + 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 += `
`; + + 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 += `
`; + + 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; + } + } + ] + }); +} diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index b9aa788d06..1a6cddadcc 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -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]; diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js index 62a5935e69..8abd5275a7 100644 --- a/InvenTree/templates/js/translated/table_filters.js +++ b/InvenTree/templates/js/translated/table_filters.js @@ -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" %}', + }, }; } diff --git a/InvenTree/templates/price_data.html b/InvenTree/templates/price_data.html new file mode 100644 index 0000000000..e7cd03e17a --- /dev/null +++ b/InvenTree/templates/price_data.html @@ -0,0 +1,8 @@ +{% load inventree_extras %} +{% load i18n %} + +{% if price %} +{% render_currency price %} +{% else %} +{% trans "No data" %} +{% endif %} diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index b03c9742f6..ad5b6b281e 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -85,6 +85,7 @@ class RuleSet(models.Model): ], 'part': [ 'part_part', + 'part_partpricing', 'part_bomitem', 'part_bomitemsubstitute', 'part_partattachment', diff --git a/ci/check_js_templates.py b/ci/check_js_templates.py index 54cd274afb..0b80901ffa 100644 --- a/ci/check_js_templates.py +++ b/ci/check_js_templates.py @@ -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 diff --git a/tasks.py b/tasks.py index 97309a980f..67052ff8a7 100644 --- a/tasks.py +++ b/tasks.py @@ -543,7 +543,7 @@ def test(c, database=None): manage(c, 'test', pty=True) -@task(help={'dev': 'Set up development enviroment at the end'}) +@task(help={'dev': 'Set up development environment at the end'}) def setup_test(c, ignore_update=False, dev=False, path="inventree-demo-dataset"): """Setup a testing enviroment."""