From 6090ddfdf34773c71ce7672ddadc80660a9c6356 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 21 Nov 2023 14:53:45 +1100 Subject: [PATCH] Part pricing override (#5956) * Add override fields for part pricing * Allow pricing override values to be specified via the API * Fix serializer * Update pricing docs * Add UI elements for manually overriding pricing data * Increment API version --- InvenTree/InvenTree/api_version.py | 5 +- .../migrations/0119_auto_20231120_0457.py | 36 ++++++++++ InvenTree/part/models.py | 21 ++++++ InvenTree/part/serializers.py | 72 +++++++++++++++++-- InvenTree/part/templates/part/prices.html | 11 +++ .../templates/part/pricing_javascript.html | 19 +++++ docs/docs/part/pricing.md | 11 +++ 7 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 InvenTree/part/migrations/0119_auto_20231120_0457.py diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 409c19aad0..8c0cc057cc 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,14 @@ # InvenTree API version -INVENTREE_API_VERSION = 152 +INVENTREE_API_VERSION = 153 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v153 -> 2023-11-21 : https://github.com/inventree/InvenTree/pull/5956 + - Adds override_min and override_max fields to part pricing API + v152 -> 2023-11-20 : https://github.com/inventree/InvenTree/pull/5949 - Adds barcode support for manufacturerpart model - Adds API endpoint for adding parts to purchase order using barcode scan diff --git a/InvenTree/part/migrations/0119_auto_20231120_0457.py b/InvenTree/part/migrations/0119_auto_20231120_0457.py new file mode 100644 index 0000000000..f63ccc70db --- /dev/null +++ b/InvenTree/part/migrations/0119_auto_20231120_0457.py @@ -0,0 +1,36 @@ +# Generated by Django 3.2.23 on 2023-11-20 04:57 + +import InvenTree.fields +from django.db import migrations +import djmoney.models.fields +import djmoney.models.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0118_auto_20231024_1844'), + ] + + operations = [ + migrations.AddField( + model_name='partpricing', + name='override_max', + field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Override maximum cost', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Maximum Cost'), + ), + migrations.AddField( + model_name='partpricing', + name='override_max_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + migrations.AddField( + model_name='partpricing', + name='override_min', + field=InvenTree.fields.InvenTreeModelMoneyField(blank=True, currency_choices=[], decimal_places=6, default_currency='', help_text='Override minimum cost', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Minimum Cost'), + ), + migrations.AddField( + model_name='partpricing', + name='override_min_currency', + field=djmoney.models.fields.CurrencyField(choices=[], default='', editable=False, max_length=3, null=True), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 4af2fc1b61..33fdd16ee1 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -2369,6 +2369,7 @@ class PartPricing(common.models.MetaMixin): def update_pricing(self, counter: int = 0, cascade: bool = True): """Recalculate all cost data for the referenced Part instance""" # If importing data, skip pricing update + if InvenTree.ready.isImportingData(): return @@ -2698,6 +2699,7 @@ class PartPricing(common.models.MetaMixin): Here we simply take the minimum / maximum values of the other calculated fields. """ + overall_min = None overall_max = None @@ -2758,7 +2760,14 @@ class PartPricing(common.models.MetaMixin): if self.internal_cost_max is not None: overall_max = self.internal_cost_max + if self.override_min is not None: + overall_min = self.convert(self.override_min) + self.overall_min = overall_min + + if self.override_max is not None: + overall_max = self.convert(self.override_max) + self.overall_max = overall_max def update_sale_cost(self, save=True): @@ -2897,6 +2906,18 @@ class PartPricing(common.models.MetaMixin): help_text=_('Calculated maximum cost of variant parts'), ) + override_min = InvenTree.fields.InvenTreeModelMoneyField( + null=True, blank=True, + verbose_name=_('Minimum Cost'), + help_text=_('Override minimum cost'), + ) + + override_max = InvenTree.fields.InvenTreeModelMoneyField( + null=True, blank=True, + verbose_name=_('Maximum Cost'), + help_text=_('Override maximum cost'), + ) + overall_min = InvenTree.fields.InvenTreeModelMoneyField( null=True, blank=True, verbose_name=_('Minimum Cost'), diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 29c8c8860a..8bf7db4d03 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -5,6 +5,7 @@ import io import logging from decimal import Decimal +from django.core.exceptions import ValidationError from django.core.files.base import ContentFile from django.core.validators import MinValueValidator from django.db import IntegrityError, models, transaction @@ -13,11 +14,14 @@ from django.db.models.functions import Coalesce from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ +from djmoney.contrib.exchange.exceptions import MissingRate +from djmoney.contrib.exchange.models import convert_money from rest_framework import serializers from sql_util.utils import SubqueryCount, SubquerySum from taggit.serializers import TagListSerializerField import common.models +import common.settings import company.models import InvenTree.helpers import InvenTree.serializers @@ -1042,6 +1046,10 @@ class PartPricingSerializer(InvenTree.serializers.InvenTreeModelSerializer): 'supplier_price_max', 'variant_cost_min', 'variant_cost_max', + 'override_min', + 'override_min_currency', + 'override_max', + 'override_max_currency', 'overall_min', 'overall_max', 'sale_price_min', @@ -1073,6 +1081,30 @@ class PartPricingSerializer(InvenTree.serializers.InvenTreeModelSerializer): variant_cost_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) variant_cost_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) + override_min = InvenTree.serializers.InvenTreeMoneySerializer( + label=_('Minimum Price'), + help_text=_('Override calculated value for minimum price'), + allow_null=True, read_only=False, required=False, + ) + + override_min_currency = serializers.ChoiceField( + label=_('Minimum price currency'), + read_only=False, required=False, + choices=common.settings.currency_code_mappings(), + ) + + override_max = InvenTree.serializers.InvenTreeMoneySerializer( + label=_('Maximum Price'), + help_text=_('Override calculated value for maximum price'), + allow_null=True, read_only=False, required=False, + ) + + override_max_currency = serializers.ChoiceField( + label=_('Maximum price currency'), + read_only=False, required=False, + choices=common.settings.currency_code_mappings(), + ) + overall_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) overall_max = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True) @@ -1086,18 +1118,44 @@ class PartPricingSerializer(InvenTree.serializers.InvenTreeModelSerializer): write_only=True, label=_('Update'), help_text=_('Update pricing for this part'), - default=False, - required=False, + default=False, required=False, allow_null=True, ) + def validate(self, data): + """Validate supplied pricing data""" + + super().validate(data) + + # Check that override_min is not greater than override_max + override_min = data.get('override_min', None) + override_max = data.get('override_max', None) + + default_currency = common.settings.currency_code_default() + + if override_min is not None and override_max is not None: + + try: + override_min = convert_money(override_min, default_currency) + override_max = convert_money(override_max, default_currency) + except MissingRate: + raise ValidationError(_(f'Could not convert from provided currencies to {default_currency}')) + + if override_min > override_max: + raise ValidationError({ + 'override_min': _('Minimum price must not be greater than maximum price'), + 'override_max': _('Maximum price must not be less than minimum price') + }) + + return data + def save(self): """Called when the serializer is saved""" - data = self.validated_data - if InvenTree.helpers.str2bool(data.get('update', False)): - # Update part pricing - pricing = self.instance - pricing.update_pricing() + super().save() + + # Update part pricing + pricing = self.instance + pricing.update_pricing() class PartRelationSerializer(InvenTree.serializers.InvenTreeModelSerializer): diff --git a/InvenTree/part/templates/part/prices.html b/InvenTree/part/templates/part/prices.html index 62f082b69d..0328e84e15 100644 --- a/InvenTree/part/templates/part/prices.html +++ b/InvenTree/part/templates/part/prices.html @@ -14,6 +14,9 @@ + @@ -97,6 +100,14 @@ {% render_currency pricing.variant_cost_max %} {% endif %} + {% if pricing.override_min or pricing.override_max %} + + + {% trans "Pricing Overrides" %} + {% render_currency pricing.override_min currency=pricing.override_min_currency %} + {% render_currency pricing.override_max currency=pricing.override_max_currency %} + + {% endif %} {% trans "Overall Pricing" %} diff --git a/InvenTree/part/templates/part/pricing_javascript.html b/InvenTree/part/templates/part/pricing_javascript.html index 94592b2af1..42976e530e 100644 --- a/InvenTree/part/templates/part/pricing_javascript.html +++ b/InvenTree/part/templates/part/pricing_javascript.html @@ -19,6 +19,25 @@ $('#part-pricing-refresh').click(function() { ); }); +$('#part-pricing-edit').click(function() { + constructForm('{% url "api-part-pricing" part.pk %}', { + title: '{% trans "Update Pricing" %}', + fields: { + override_min: {}, + override_min_currency: {}, + override_max: {}, + override_max_currency: {}, + update: { + hidden: true, + value: true, + } + }, + onSuccess: function(response) { + location.reload(); + } + }); +}); + // Internal Pricebreaks {% if show_internal_price and roles.sales_order.view %} initPriceBreakSet($('#internal-price-break-table'), { diff --git a/docs/docs/part/pricing.md b/docs/docs/part/pricing.md index bcf21871ce..7ba1352de4 100644 --- a/docs/docs/part/pricing.md +++ b/docs/docs/part/pricing.md @@ -28,6 +28,17 @@ Pricing information can be determined from multiple sources: | Supplier Price | The price to theoretically purchase a part from a given supplier (with price-breaks) | [Supplier](../order/company.md#suppliers) | | Purchase Cost | Historical cost information for parts purchased | [Purchase Order](../order/purchase_order.md) | | BOM Price | Total price for an assembly (total price of all component items) | [Part](../part/part.md) | + +#### Override Pricing + +In addition to caching pricing data as documented in the above table, manual pricing overrides can be specified for a particular part. Both the *minimum price* and *maximum price* can be specified manually, independent of the calculated values. If an manual price is specified for a part, it overrides any calculated value. + +### Sale Pricing + +Additionally, the following information is stored for each part, in relation to sale pricing: + +| Pricing Source | Description | Linked to | +| --- | --- | --- | | Sale Price | How much a salable item is sold for (with price-breaks) | [Part](../part/part.md) | | Sale Cost | How much an item was sold for | [Sales Order](../order/sales_order.md) |