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
This commit is contained in:
Oliver 2023-11-21 14:53:45 +11:00 committed by GitHub
parent dabd95db85
commit 6090ddfdf3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 167 additions and 8 deletions

View File

@ -2,11 +2,14 @@
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v152 -> 2023-11-20 : https://github.com/inventree/InvenTree/pull/5949
- Adds barcode support for manufacturerpart model - Adds barcode support for manufacturerpart model
- Adds API endpoint for adding parts to purchase order using barcode scan - Adds API endpoint for adding parts to purchase order using barcode scan

View File

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

View File

@ -2369,6 +2369,7 @@ class PartPricing(common.models.MetaMixin):
def update_pricing(self, counter: int = 0, cascade: bool = True): def update_pricing(self, counter: int = 0, cascade: bool = True):
"""Recalculate all cost data for the referenced Part instance""" """Recalculate all cost data for the referenced Part instance"""
# If importing data, skip pricing update # If importing data, skip pricing update
if InvenTree.ready.isImportingData(): if InvenTree.ready.isImportingData():
return return
@ -2698,6 +2699,7 @@ class PartPricing(common.models.MetaMixin):
Here we simply take the minimum / maximum values of the other calculated fields. Here we simply take the minimum / maximum values of the other calculated fields.
""" """
overall_min = None overall_min = None
overall_max = None overall_max = None
@ -2758,7 +2760,14 @@ class PartPricing(common.models.MetaMixin):
if self.internal_cost_max is not None: if self.internal_cost_max is not None:
overall_max = self.internal_cost_max 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 self.overall_min = overall_min
if self.override_max is not None:
overall_max = self.convert(self.override_max)
self.overall_max = overall_max self.overall_max = overall_max
def update_sale_cost(self, save=True): def update_sale_cost(self, save=True):
@ -2897,6 +2906,18 @@ class PartPricing(common.models.MetaMixin):
help_text=_('Calculated maximum cost of variant parts'), 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( overall_min = InvenTree.fields.InvenTreeModelMoneyField(
null=True, blank=True, null=True, blank=True,
verbose_name=_('Minimum Cost'), verbose_name=_('Minimum Cost'),

View File

@ -5,6 +5,7 @@ import io
import logging import logging
from decimal import Decimal from decimal import Decimal
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import IntegrityError, models, transaction 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.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _ 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 rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum from sql_util.utils import SubqueryCount, SubquerySum
from taggit.serializers import TagListSerializerField from taggit.serializers import TagListSerializerField
import common.models import common.models
import common.settings
import company.models import company.models
import InvenTree.helpers import InvenTree.helpers
import InvenTree.serializers import InvenTree.serializers
@ -1042,6 +1046,10 @@ class PartPricingSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'supplier_price_max', 'supplier_price_max',
'variant_cost_min', 'variant_cost_min',
'variant_cost_max', 'variant_cost_max',
'override_min',
'override_min_currency',
'override_max',
'override_max_currency',
'overall_min', 'overall_min',
'overall_max', 'overall_max',
'sale_price_min', '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_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
variant_cost_max = 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_min = InvenTree.serializers.InvenTreeMoneySerializer(allow_null=True, read_only=True)
overall_max = 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, write_only=True,
label=_('Update'), label=_('Update'),
help_text=_('Update pricing for this part'), help_text=_('Update pricing for this part'),
default=False, default=False, required=False, allow_null=True,
required=False,
) )
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): def save(self):
"""Called when the serializer is saved""" """Called when the serializer is saved"""
data = self.validated_data
if InvenTree.helpers.str2bool(data.get('update', False)): super().save()
# Update part pricing
pricing = self.instance # Update part pricing
pricing.update_pricing() pricing = self.instance
pricing.update_pricing()
class PartRelationSerializer(InvenTree.serializers.InvenTreeModelSerializer): class PartRelationSerializer(InvenTree.serializers.InvenTreeModelSerializer):

View File

@ -14,6 +14,9 @@
<button type='button' class='btn btn-success' id='part-pricing-refresh' title='{% trans "Refresh Part Pricing" %}'> <button type='button' class='btn btn-success' id='part-pricing-refresh' title='{% trans "Refresh Part Pricing" %}'>
<span class='fas fa-redo-alt'></span> {% trans "Refresh" %} <span class='fas fa-redo-alt'></span> {% trans "Refresh" %}
</button> </button>
<button type='button' class='btn btn-success' id='part-pricing-edit' title='{% trans "Override Part Pricing" %}'>
<span class='fas fa-edit'></span> {% trans "Edit" %}
</button>
</div> </div>
</div> </div>
</div> </div>
@ -97,6 +100,14 @@
<td>{% render_currency pricing.variant_cost_max %}</td> <td>{% render_currency pricing.variant_cost_max %}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if pricing.override_min or pricing.override_max %}
<tr>
<td><a href='#pricing-overrides'><span class='fas fa-exclamation-circle'></span></a></td>
<th>{% trans "Pricing Overrides" %}</th>
<td>{% render_currency pricing.override_min currency=pricing.override_min_currency %}</td>
<td>{% render_currency pricing.override_max currency=pricing.override_max_currency %}</td>
</tr>
{% endif %}
<tr> <tr>
<td></td> <td></td>
<th>{% trans "Overall Pricing" %}</th> <th>{% trans "Overall Pricing" %}</th>

View File

@ -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 // Internal Pricebreaks
{% if show_internal_price and roles.sales_order.view %} {% if show_internal_price and roles.sales_order.view %}
initPriceBreakSet($('#internal-price-break-table'), { initPriceBreakSet($('#internal-price-break-table'), {

View File

@ -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) | | 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) | | 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) | | 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 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) | | Sale Cost | How much an item was sold for | [Sales Order](../order/sales_order.md) |