-
-
- {% trans "Add Price Break" %}
-
-
-
-
+
-
-{% 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" %}
-
-
-
- {% trans "Base Currency" %}
- {{ base_currency }}
-
-
-
- {% trans "Exchange Rates" %}
-
- {% for rate in rates %}
-
-
- {{ rate.value }}
- {{ rate.currency }}
-
-
-
- {% endfor %}
-
-
-
- {% trans "Last Update" %}
-
-
- {% if rates_updated %}
- {{ rates_updated }}
- {% else %}
- {% trans "Never" %}
- {% endif %}
-
-
-
-
-
-
-{% 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" %}
+
+
+
+
+ {% 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" %}
+
+
+
+ {% trans "Base Currency" %}
+ {{ base_currency }}
+
+
+
+ {% trans "Exchange Rates" %}
+ {% trans "Currency" %}
+ {% trans "Rate" %}
+
+ {% for rate in rates %}
+
+
+
+ {{ rate.currency }}
+ {{ rate.value }}
+
+
+ {% endfor %}
+
+
+
+{% 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."""