mirror of
https://github.com/inventree/InvenTree.git
synced 2025-08-09 21:30:54 +00:00
Part pricing cache (#3710)
* Create new model for storing Part pricing data
Currently this model does not "do" anything but will be used for caching pre-calculated pricing information
* Define function for accessing pricing information for a specific part
* Adds admin site support for new PartPricing model
* Specify role for PartPricing model
* Allow blank values for PartPricing model fields
* Add some TODO entries
* Update migration files to sync with latest master
* Expose API endpoint for viewing part pricing information
* Update migration file
* Improvements:
- Updated model with new fields
- Code for calculating BOM price
- Code for calculating internal price
- Code for calculating supplier price
- Updated unit testing
* Fix (and test) for API serializer
* Including min/max pricing data in part serializer
* Bump API version
* Add pricing overview information in part table
- Adds helper function for formatting currency data
- No longer pre-render "price strings" on the server
* Overhaul of BOM API
- Pricing data no longer calculated "on the fly"
- Remove expensive annotation operations
- Display cached price range information in BOM table
* Filter BOM items by "has pricing"
* Part API endpoint can be filtered by price range
* Updpated API version notes
* Improvements for price caching calculations
- Handle null price values
- Handle case where conversion rates are missing
- Allow manual update via API
* Button to manually refresh pricing
* Improve rendering of price-break table
* Update supplier part pricing table
* Updated js functions
* Adds background task to update assembly pricing whenever a part price cache is changed
* Updates for task offloading
* HTML tweaks
* Implement calculation of historical purchase cost
- take supplier part pack size into account
- improve unit tests
* Improvements for pricing tab rendering
* Refactor of pricing page
- Move javascript functions out into separate files
- Change price-break tables to use bar graphs
- Display part pricing history table and chart
- Remove server-side rendering for price history data
- Fix rendering of supplier pricing table
- Adds extra filtering options to the SupplierPriceBreak API endpoint
* Refactor BOM pricing chart / table
- Display as bar chart with min/max pricing
- Display simplified BOM table
* Update page anchors
* Improvements for BOM pricing table display
* Refactoring sales data tables
- Add extra data and filter options to sales order API endpoints
- Display sales order history table and chart
* Add extra fields to PartPricing model:
- sale_price_min
- sale_price_max
- sale_history_min
- sale_history_max
* Calculate and cache sale price data
* Update part pricing when PurchaseOrder is completed
* Update part pricing when sales order is completed
* Signals for updating part pricing cache
- Whenever an internal price break is created / edited / deleted
- Whenever a sale price break is created / edited / deleted
* Also trigger part pricing update when BomItem is created / edited / deleted
* Update part pricing whenever a supplier price break is updated
* Remove has_complete_bom_pricing method
* Export min/max pricing data in BOM file
* Fix pricing data in BOM export
- Calculate total line cost
- Use more than two digits
* Add pricing information to part export
Also some improvements to part exporting
* Allow download of part category table
* Allow export of stock location data to file
* Improved exporting of StockItem data
* Add cached variant pricing data
- New fields in part pricing model
- Display variant pricing overview in "pricing" tab
* Remove outdated "PART_SHOW_PRICE_HISTORY" setting
* Adds scheduled background task to periodically update part pricing
* Internal prices can optionally override other pricing
* Update js file checks
* Update price breaks to use 6 decimal places
* Fix for InvenTreeMoneySerializer class
- Allow 6 decimal places through the API
* Update for supplier price break table
* javascript linting fix
* Further js fixes
* Unit test updates
* Improve rendering of currency in templates
- Do not artificially limit to 2 decimal places
* Unit test fixes
* Add pricing information to part "details" tab
* Tweak for money formatting
* Enable sort-by-price in BOM table
* More unit test tweaks
* Update BOM exporting
* Fixes for background worker process
- To determine if worker is running, look for *any* successful task, not just heartbeat
- Heartbeat rate increased to 5 minute intervals
- Small adjustments to django_q settings
Ref: https://github.com/inventree/InvenTree/issues/3921
(cherry picked from commit cb26003b92
)
* Force background processing of heartbeat task when server is started
- Removes the ~5 minute window in which the server "thinks" that the worker is not actually running
* Adjust strategy for preventing recursion
- Rather than looking for duplicate parts, simply increment a counter
- Add a "scheduled_for_update" flag to prevent multiple updates being scheduled
- Consolidate migration files
* Adds helper function for rendering a range of prices
* Include variant cost in calculations
* Fixes for "has_pricing" API filters
* Ensure part pricing status flags are reset when the server restarts
* Bug fix for BOM API filter
* Include BOM quantity in BOM pricing chart
* Small tweaks to pricing tab
* Prevent caching when looking up settings in background worker
- Caching across mnultiple processes causes issues
- Need to move to something like redis to solve this
- Ref: https://github.com/inventree/InvenTree/issues/3921
* Fixes for /part/pricing/ detail API endpoint
* Update pricing tab
- Consistent naming
* Unit test fixes
* Prevent pricing updates when loading test fixtures
* Fix for Part.pricing
* Updates for "check_missing_pricing"
* Change to pie chart for BOM pricing
* Unit test fix
* Updates
- Sort BOM pie chart correctly
- Simplify PartPricing.is_valid
- Pass "limit" through to check_missing_pricing
- Improved logic for update scheduling
* Add option for changing how many decimals to use when displaying pricing data
* remove old unused setting
* Consolidate settings tabs for pricing and currencies
* Fix CI after changing settings page
* Fix rendering for "Supplier Pricing"
- Take unit pricing / pack size into account
* Extra filtering / ordering options for the SupplierPriceBreak API endpoint
* Fix for purchase price history graph
- Use unit pricing (take pack size into account)
* JS fixes
This commit is contained in:
@@ -57,6 +57,12 @@ class SupplierPartResource(InvenTreeResource):
|
||||
clean_model_instances = True
|
||||
|
||||
|
||||
class SupplierPriceBreakInline(admin.TabularInline):
|
||||
"""Inline for supplier-part pricing"""
|
||||
|
||||
model = SupplierPriceBreak
|
||||
|
||||
|
||||
class SupplierPartAdmin(ImportExportModelAdmin):
|
||||
"""Admin class for the SupplierPart model"""
|
||||
|
||||
@@ -71,6 +77,10 @@ class SupplierPartAdmin(ImportExportModelAdmin):
|
||||
'SKU',
|
||||
]
|
||||
|
||||
inlines = [
|
||||
SupplierPriceBreakInline,
|
||||
]
|
||||
|
||||
autocomplete_fields = ('part', 'supplier', 'manufacturer_part',)
|
||||
|
||||
|
||||
|
@@ -7,6 +7,7 @@ from django_filters import rest_framework as rest_filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import filters
|
||||
|
||||
import part.models
|
||||
from InvenTree.api import AttachmentMixin, ListCreateDestroyAPIView
|
||||
from InvenTree.filters import InvenTreeOrderingFilter
|
||||
from InvenTree.helpers import str2bool
|
||||
@@ -354,9 +355,6 @@ class SupplierPartList(ListCreateDestroyAPIView):
|
||||
InvenTreeOrderingFilter,
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
]
|
||||
|
||||
ordering_fields = [
|
||||
'SKU',
|
||||
'part',
|
||||
@@ -403,6 +401,31 @@ class SupplierPartDetail(RetrieveUpdateDestroyAPI):
|
||||
]
|
||||
|
||||
|
||||
class SupplierPriceBreakFilter(rest_filters.FilterSet):
|
||||
"""Custom API filters for the SupplierPriceBreak list endpoint"""
|
||||
|
||||
base_part = rest_filters.ModelChoiceFilter(
|
||||
label='Base Part',
|
||||
queryset=part.models.Part.objects.all(),
|
||||
field_name='part__part',
|
||||
)
|
||||
|
||||
supplier = rest_filters.ModelChoiceFilter(
|
||||
label='Supplier',
|
||||
queryset=Company.objects.all(),
|
||||
field_name='part__supplier',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options"""
|
||||
|
||||
model = SupplierPriceBreak
|
||||
fields = [
|
||||
'part',
|
||||
'quantity',
|
||||
]
|
||||
|
||||
|
||||
class SupplierPriceBreakList(ListCreateAPI):
|
||||
"""API endpoint for list view of SupplierPriceBreak object.
|
||||
|
||||
@@ -412,15 +435,35 @@ class SupplierPriceBreakList(ListCreateAPI):
|
||||
|
||||
queryset = SupplierPriceBreak.objects.all()
|
||||
serializer_class = SupplierPriceBreakSerializer
|
||||
filterset_class = SupplierPriceBreakFilter
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return serializer instance for this endpoint"""
|
||||
|
||||
try:
|
||||
params = self.request.query_params
|
||||
|
||||
kwargs['part_detail'] = str2bool(params.get('part_detail', False))
|
||||
kwargs['supplier_detail'] = str2bool(params.get('supplier_detail', False))
|
||||
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
|
||||
filterset_fields = [
|
||||
'part',
|
||||
ordering_fields = [
|
||||
'quantity',
|
||||
]
|
||||
|
||||
ordering = 'quantity'
|
||||
|
||||
|
||||
class SupplierPriceBreakDetail(RetrieveUpdateDestroyAPI):
|
||||
"""Detail endpoint for SupplierPriceBreak object."""
|
||||
|
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 3.2.16 on 2022-11-11 01:50
|
||||
|
||||
import InvenTree.fields
|
||||
from django.db import migrations
|
||||
import djmoney.models.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('company', '0050_alter_company_website'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='supplierpricebreak',
|
||||
name='price',
|
||||
field=InvenTree.fields.InvenTreeModelMoneyField(currency_choices=[], decimal_places=6, default_currency='', help_text='Unit price at specified quantity', max_digits=19, null=True, validators=[djmoney.models.validators.MinMoneyValidator(0)], verbose_name='Price'),
|
||||
),
|
||||
]
|
@@ -8,6 +8,8 @@ from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q, Sum, UniqueConstraint
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -18,6 +20,8 @@ import common.models
|
||||
import common.settings
|
||||
import InvenTree.fields
|
||||
import InvenTree.helpers
|
||||
import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
import InvenTree.validators
|
||||
from common.settings import currency_code_default
|
||||
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
|
||||
@@ -691,3 +695,23 @@ class SupplierPriceBreak(common.models.PriceBreak):
|
||||
def __str__(self):
|
||||
"""Format a string representation of a SupplierPriceBreak instance"""
|
||||
return f'{self.part.SKU} - {self.price} @ {self.quantity}'
|
||||
|
||||
|
||||
@receiver(post_save, sender=SupplierPriceBreak, dispatch_uid='post_save_supplier_price_break')
|
||||
def after_save_supplier_price(sender, instance, created, **kwargs):
|
||||
"""Callback function when a SupplierPriceBreak is created or updated"""
|
||||
|
||||
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
|
||||
|
||||
if instance.part and instance.part.part:
|
||||
instance.part.part.pricing.schedule_for_update()
|
||||
|
||||
|
||||
@receiver(post_delete, sender=SupplierPriceBreak, dispatch_uid='post_delete_supplier_price_break')
|
||||
def after_delete_supplier_price(sender, instance, **kwargs):
|
||||
"""Callback function when a SupplierPriceBreak is deleted"""
|
||||
|
||||
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
|
||||
|
||||
if instance.part and instance.part.part:
|
||||
instance.part.part.pricing.schedule_for_update()
|
||||
|
@@ -141,7 +141,7 @@ class ManufacturerPartSerializer(InvenTreeModelSerializer):
|
||||
manufacturer_detail = kwargs.pop('manufacturer_detail', True)
|
||||
prettify = kwargs.pop('pretty', False)
|
||||
|
||||
super(ManufacturerPartSerializer, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if part_detail is not True:
|
||||
self.fields.pop('part_detail')
|
||||
@@ -205,7 +205,7 @@ class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
|
||||
"""Initialize this serializer with extra detail fields as required"""
|
||||
man_detail = kwargs.pop('manufacturer_part_detail', False)
|
||||
|
||||
super(ManufacturerPartParameterSerializer, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not man_detail:
|
||||
self.fields.pop('manufacturer_part_detail')
|
||||
@@ -247,13 +247,17 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
# Check if 'available' quantity was supplied
|
||||
self.has_available_quantity = 'available' in kwargs.get('data', {})
|
||||
|
||||
part_detail = kwargs.pop('part_detail', True)
|
||||
supplier_detail = kwargs.pop('supplier_detail', True)
|
||||
manufacturer_detail = kwargs.pop('manufacturer_detail', True)
|
||||
brief = kwargs.pop('brief', False)
|
||||
|
||||
detail_default = not brief
|
||||
|
||||
part_detail = kwargs.pop('part_detail', detail_default)
|
||||
supplier_detail = kwargs.pop('supplier_detail', detail_default)
|
||||
manufacturer_detail = kwargs.pop('manufacturer_detail', detail_default)
|
||||
|
||||
prettify = kwargs.pop('pretty', False)
|
||||
|
||||
super(SupplierPartSerializer, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if part_detail is not True:
|
||||
self.fields.pop('part_detail')
|
||||
@@ -263,6 +267,7 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
|
||||
if manufacturer_detail is not True:
|
||||
self.fields.pop('manufacturer_detail')
|
||||
self.fields.pop('manufacturer_part_detail')
|
||||
|
||||
if prettify is not True:
|
||||
self.fields.pop('pretty_name')
|
||||
@@ -366,6 +371,20 @@ class SupplierPartSerializer(InvenTreeModelSerializer):
|
||||
class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
|
||||
"""Serializer for SupplierPriceBreak object."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize this serializer with extra fields as required"""
|
||||
|
||||
supplier_detail = kwargs.pop('supplier_detail', False)
|
||||
part_detail = kwargs.pop('part_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not supplier_detail:
|
||||
self.fields.pop('supplier_detail')
|
||||
|
||||
if not part_detail:
|
||||
self.fields.pop('part_detail')
|
||||
|
||||
quantity = InvenTreeDecimalField()
|
||||
|
||||
price = InvenTreeMoneySerializer(
|
||||
@@ -380,6 +399,13 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
|
||||
label=_('Currency'),
|
||||
)
|
||||
|
||||
supplier = serializers.PrimaryKeyRelatedField(source='part.supplier', many=False, read_only=True)
|
||||
|
||||
supplier_detail = CompanyBriefSerializer(source='part.supplier', many=False, read_only=True)
|
||||
|
||||
# Detail serializer for SupplierPart
|
||||
part_detail = SupplierPartSerializer(source='part', brief=True, many=False, read_only=True)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
@@ -387,8 +413,11 @@ class SupplierPriceBreakSerializer(InvenTreeModelSerializer):
|
||||
fields = [
|
||||
'pk',
|
||||
'part',
|
||||
'part_detail',
|
||||
'quantity',
|
||||
'price',
|
||||
'price_currency',
|
||||
'supplier',
|
||||
'supplier_detail',
|
||||
'updated',
|
||||
]
|
||||
|
@@ -252,6 +252,9 @@ src="{% static 'img/blank_image.png' %}"
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
<div id='price-break-toolbar' class='btn-group'>
|
||||
<div class='btn-group' role='group'>
|
||||
{% include "filter_list.html" with id='supplierpricebreak' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class='table table-striped table-condensed' id='price-break-table' data-toolbar='#price-break-toolbar'>
|
||||
@@ -291,82 +294,8 @@ $("#barcode-unlink").click(function() {
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
function reloadPriceBreaks() {
|
||||
$("#price-break-table").bootstrapTable("refresh");
|
||||
}
|
||||
|
||||
$('#price-break-table').inventreeTable({
|
||||
name: 'buypricebreaks',
|
||||
formatNoMatches: function() { return "{% trans "No price break information found" %}"; },
|
||||
queryParams: {
|
||||
part: {{ part.id }},
|
||||
},
|
||||
url: "{% url 'api-part-supplier-price-list' %}",
|
||||
onPostBody: function() {
|
||||
var table = $('#price-break-table');
|
||||
|
||||
table.find('.button-price-break-delete').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`/api/company/price-break/${pk}/`, {
|
||||
method: 'DELETE',
|
||||
onSuccess: reloadPriceBreaks,
|
||||
title: '{% trans "Delete Price Break" %}',
|
||||
});
|
||||
});
|
||||
|
||||
table.find('.button-price-break-edit').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`/api/company/price-break/${pk}/`, {
|
||||
fields: {
|
||||
quantity: {},
|
||||
price: {},
|
||||
price_currency: {},
|
||||
},
|
||||
onSuccess: reloadPriceBreaks,
|
||||
title: '{% trans "Edit Price Break" %}',
|
||||
});
|
||||
});
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
field: 'pk',
|
||||
title: 'ID',
|
||||
visible: false,
|
||||
switchable: false,
|
||||
},
|
||||
{
|
||||
field: 'quantity',
|
||||
title: '{% trans "Quantity" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'price',
|
||||
title: '{% trans "Price" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row, index) {
|
||||
var html = value;
|
||||
|
||||
html += `<div class='btn-group float-right' role='group'>`
|
||||
|
||||
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 += `</div>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'updated',
|
||||
title: '{% trans "Last updated" %}',
|
||||
sortable: true,
|
||||
formatter: function(value) {
|
||||
return renderDate(value);
|
||||
}
|
||||
},
|
||||
]
|
||||
loadSupplierPriceBreakTable({
|
||||
part: {{ part.pk }}
|
||||
});
|
||||
|
||||
$('#new-price-break').click(function() {
|
||||
@@ -386,7 +315,9 @@ $('#new-price-break').click(function() {
|
||||
},
|
||||
},
|
||||
title: '{% trans "Add Price Break" %}',
|
||||
onSuccess: reloadPriceBreaks,
|
||||
onSuccess: function() {
|
||||
$("#price-break-table").bootstrapTable("refresh");
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@@ -239,12 +239,6 @@ class ManufacturerTest(InvenTreeAPITestCase):
|
||||
# Check link is not modified
|
||||
self.assertEqual(response.data['link'], 'https://www.axel-larsson.se/Exego.aspx?p_id=341&ArtNr=0804020E')
|
||||
|
||||
# Check manufacturer part
|
||||
manufacturer_part_id = int(response.data['manufacturer_part_detail']['pk'])
|
||||
url = reverse('api-manufacturer-part-detail', kwargs={'pk': manufacturer_part_id})
|
||||
response = self.get(url)
|
||||
self.assertEqual(response.data['MPN'], 'PART_NUMBER')
|
||||
|
||||
# Check link is not modified
|
||||
self.assertEqual(response.data['link'], 'https://www.axel-larsson.se/Exego.aspx?p_id=341&ArtNr=0804020E')
|
||||
|
||||
|
Reference in New Issue
Block a user