2
0
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:
Oliver
2022-11-14 15:58:22 +11:00
committed by GitHub
parent 8ceb1af3c3
commit 06266b48af
69 changed files with 3747 additions and 1629 deletions

View File

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

View File

@@ -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."""

View File

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

View File

@@ -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()

View File

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

View File

@@ -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");
}
}
);
});

View File

@@ -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')