mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
dabd95db85
commit
6090ddfdf3
@ -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
|
||||||
|
36
InvenTree/part/migrations/0119_auto_20231120_0457.py
Normal file
36
InvenTree/part/migrations/0119_auto_20231120_0457.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
@ -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'),
|
||||||
|
@ -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):
|
||||||
|
@ -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>
|
||||||
|
@ -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'), {
|
||||||
|
@ -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) |
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user