mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +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