mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-05 13:10:57 +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:
@ -15,7 +15,7 @@ from django.core.validators import MinValueValidator
|
||||
from django.db import models, transaction
|
||||
from django.db.models import ExpressionWrapper, F, Q, Sum, UniqueConstraint
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models.signals import post_save
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.db.utils import IntegrityError
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
@ -24,6 +24,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django_cleanup import cleanup
|
||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||
from djmoney.contrib.exchange.models import convert_money
|
||||
from djmoney.money import Money
|
||||
from jinja2 import Template
|
||||
from mptt.exceptions import InvalidMove
|
||||
from mptt.managers import TreeManager
|
||||
@ -31,6 +32,8 @@ from mptt.models import MPTTModel, TreeForeignKey
|
||||
from stdimage.models import StdImageField
|
||||
|
||||
import common.models
|
||||
import common.settings
|
||||
import InvenTree.fields
|
||||
import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
import part.filters as part_filters
|
||||
@ -308,6 +311,7 @@ class PartManager(TreeManager):
|
||||
"""Perform default prefetch operations when accessing Part model from the database"""
|
||||
return super().get_queryset().prefetch_related(
|
||||
'category',
|
||||
'pricing_data',
|
||||
'category__parent',
|
||||
'stock_items',
|
||||
'builds',
|
||||
@ -1649,15 +1653,25 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
"""Return the number of supplier parts available for this part."""
|
||||
return self.supplier_parts.count()
|
||||
|
||||
@property
|
||||
def has_complete_bom_pricing(self):
|
||||
"""Return true if there is pricing information for each item in the BOM."""
|
||||
use_internal = common.models.InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
|
||||
for item in self.get_bom_items().select_related('sub_part'):
|
||||
if item.sub_part.get_price_range(internal=use_internal) is None:
|
||||
return False
|
||||
def update_pricing(self):
|
||||
"""Recalculate cached pricing for this Part instance"""
|
||||
|
||||
return True
|
||||
self.pricing.update_pricing()
|
||||
|
||||
@property
|
||||
def pricing(self):
|
||||
"""Return the PartPricing information for this Part instance.
|
||||
|
||||
If there is no PartPricing database entry defined for this Part,
|
||||
it will first be created, and then returned.
|
||||
"""
|
||||
|
||||
try:
|
||||
pricing = PartPricing.objects.get(part=self)
|
||||
except PartPricing.DoesNotExist:
|
||||
pricing = PartPricing(part=self)
|
||||
|
||||
return pricing
|
||||
|
||||
def get_price_info(self, quantity=1, buy=True, bom=True, internal=False):
|
||||
"""Return a simplified pricing string for this part.
|
||||
@ -1800,7 +1814,7 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
max(buy_price_range[1], bom_price_range[1])
|
||||
)
|
||||
|
||||
base_cost = models.DecimalField(max_digits=10, decimal_places=3, default=0, validators=[MinValueValidator(0)], verbose_name=_('base cost'), help_text=_('Minimum charge (e.g. stocking fee)'))
|
||||
base_cost = models.DecimalField(max_digits=19, decimal_places=6, default=0, validators=[MinValueValidator(0)], verbose_name=_('base cost'), help_text=_('Minimum charge (e.g. stocking fee)'))
|
||||
|
||||
multiple = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)], verbose_name=_('multiple'), help_text=_('Sell multiple'))
|
||||
|
||||
@ -2199,6 +2213,590 @@ def after_save_part(sender, instance: Part, created, **kwargs):
|
||||
InvenTree.tasks.offload_task(part_tasks.notify_low_stock_if_required, instance)
|
||||
|
||||
|
||||
class PartPricing(models.Model):
|
||||
"""Model for caching min/max pricing information for a particular Part
|
||||
|
||||
It is prohibitively expensive to calculate min/max pricing for a part "on the fly".
|
||||
As min/max pricing does not change very often, we pre-calculate and cache these values.
|
||||
|
||||
Whenever pricing is updated, these values are re-calculated and stored.
|
||||
|
||||
Pricing information is cached for:
|
||||
|
||||
- BOM cost (min / max cost of component items)
|
||||
- Purchase cost (based on purchase history)
|
||||
- Internal cost (based on user-specified InternalPriceBreak data)
|
||||
- Supplier price (based on supplier part data)
|
||||
- Variant price (min / max cost of any variants)
|
||||
- Overall best / worst (based on the values listed above)
|
||||
- Sale price break min / max values
|
||||
- Historical sale pricing min / max values
|
||||
|
||||
Note that this pricing information does not take "quantity" into account:
|
||||
- This provides a simple min / max pricing range, which is quite valuable in a lot of situations
|
||||
- Quantity pricing still needs to be calculated
|
||||
- Quantity pricing can be viewed from the part detail page
|
||||
- Detailed pricing information is very context specific in any case
|
||||
"""
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
"""Return True if the cached pricing is valid"""
|
||||
|
||||
return self.updated is not None
|
||||
|
||||
def convert(self, money):
|
||||
"""Attempt to convert money value to default currency.
|
||||
|
||||
If a MissingRate error is raised, ignore it and return None
|
||||
"""
|
||||
|
||||
if money is None:
|
||||
return None
|
||||
|
||||
target_currency = currency_code_default()
|
||||
|
||||
try:
|
||||
result = convert_money(money, target_currency)
|
||||
except MissingRate:
|
||||
logger.warning(f"No currency conversion rate available for {money.currency} -> {target_currency}")
|
||||
result = None
|
||||
|
||||
return result
|
||||
|
||||
def schedule_for_update(self, counter: int = 0):
|
||||
"""Schedule this pricing to be updated"""
|
||||
|
||||
if self.pk is None:
|
||||
self.save()
|
||||
|
||||
self.refresh_from_db()
|
||||
|
||||
if self.scheduled_for_update:
|
||||
# Ignore if the pricing is already scheduled to be updated
|
||||
logger.info(f"Pricing for {self.part} already scheduled for update - skipping")
|
||||
return
|
||||
|
||||
if counter > 25:
|
||||
# Prevent infinite recursion / stack depth issues
|
||||
logger.info(counter, f"Skipping pricing update for {self.part} - maximum depth exceeded")
|
||||
return
|
||||
|
||||
self.scheduled_for_update = True
|
||||
self.save()
|
||||
|
||||
import part.tasks as part_tasks
|
||||
|
||||
# Offload task to update the pricing
|
||||
# Force async, to prevent running in the foreground
|
||||
InvenTree.tasks.offload_task(
|
||||
part_tasks.update_part_pricing,
|
||||
self,
|
||||
counter=counter,
|
||||
force_async=True
|
||||
)
|
||||
|
||||
def update_pricing(self, counter: int = 0):
|
||||
"""Recalculate all cost data for the referenced Part instance"""
|
||||
|
||||
if self.pk is not None:
|
||||
self.refresh_from_db()
|
||||
|
||||
self.update_bom_cost(save=False)
|
||||
self.update_purchase_cost(save=False)
|
||||
self.update_internal_cost(save=False)
|
||||
self.update_supplier_cost(save=False)
|
||||
self.update_variant_cost(save=False)
|
||||
self.update_sale_cost(save=False)
|
||||
|
||||
# Clear scheduling flag
|
||||
self.scheduled_for_update = False
|
||||
|
||||
# Note: save method calls update_overall_cost
|
||||
try:
|
||||
self.save()
|
||||
except IntegrityError:
|
||||
# Background worker processes may try to concurrently update
|
||||
pass
|
||||
|
||||
# Update parent assemblies and templates
|
||||
self.update_assemblies(counter)
|
||||
self.update_templates(counter)
|
||||
|
||||
def update_assemblies(self, counter: int = 0):
|
||||
"""Schedule updates for any assemblies which use this part"""
|
||||
|
||||
# If the linked Part is used in any assemblies, schedule a pricing update for those assemblies
|
||||
used_in_parts = self.part.get_used_in()
|
||||
|
||||
for p in used_in_parts:
|
||||
p.pricing.schedule_for_update(counter + 1)
|
||||
|
||||
def update_templates(self, counter: int = 0):
|
||||
"""Schedule updates for any template parts above this part"""
|
||||
|
||||
templates = self.part.get_ancestors(include_self=False)
|
||||
|
||||
for p in templates:
|
||||
p.pricing.schedule_for_update(counter + 1)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Whenever pricing model is saved, automatically update overall prices"""
|
||||
|
||||
# Update the currency which was used to perform the calculation
|
||||
self.currency = currency_code_default()
|
||||
|
||||
self.update_overall_cost()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def update_bom_cost(self, save=True):
|
||||
"""Recalculate BOM cost for the referenced Part instance.
|
||||
|
||||
Iterate through the Bill of Materials, and calculate cumulative pricing:
|
||||
|
||||
cumulative_min: The sum of minimum costs for each line in the BOM
|
||||
cumulative_max: The sum of maximum costs for each line in the BOM
|
||||
|
||||
Note: The cumulative costs are calculated based on the specified default currency
|
||||
"""
|
||||
|
||||
if not self.part.assembly:
|
||||
# Not an assembly - no BOM pricing
|
||||
self.bom_cost_min = None
|
||||
self.bom_cost_max = None
|
||||
|
||||
if save:
|
||||
self.save()
|
||||
|
||||
# Short circuit - no further operations required
|
||||
return
|
||||
|
||||
currency_code = common.settings.currency_code_default()
|
||||
|
||||
cumulative_min = Money(0, currency_code)
|
||||
cumulative_max = Money(0, currency_code)
|
||||
|
||||
any_min_elements = False
|
||||
any_max_elements = False
|
||||
|
||||
for bom_item in self.part.get_bom_items():
|
||||
# Loop through each BOM item which is used to assemble this part
|
||||
|
||||
bom_item_min = None
|
||||
bom_item_max = None
|
||||
|
||||
for sub_part in bom_item.get_valid_parts_for_allocation():
|
||||
# Check each part which *could* be used
|
||||
|
||||
sub_part_pricing = sub_part.pricing
|
||||
|
||||
sub_part_min = self.convert(sub_part_pricing.overall_min)
|
||||
sub_part_max = self.convert(sub_part_pricing.overall_max)
|
||||
|
||||
if sub_part_min is not None:
|
||||
if bom_item_min is None or sub_part_min < bom_item_min:
|
||||
bom_item_min = sub_part_min
|
||||
|
||||
if sub_part_max is not None:
|
||||
if bom_item_max is None or sub_part_max > bom_item_max:
|
||||
bom_item_max = sub_part_max
|
||||
|
||||
# Update cumulative totals
|
||||
if bom_item_min is not None:
|
||||
bom_item_min *= bom_item.quantity
|
||||
cumulative_min += self.convert(bom_item_min)
|
||||
|
||||
any_min_elements = True
|
||||
|
||||
if bom_item_max is not None:
|
||||
bom_item_max *= bom_item.quantity
|
||||
cumulative_max += self.convert(bom_item_max)
|
||||
|
||||
any_max_elements = True
|
||||
|
||||
if any_min_elements:
|
||||
self.bom_cost_min = cumulative_min
|
||||
else:
|
||||
self.bom_cost_min = None
|
||||
|
||||
if any_max_elements:
|
||||
self.bom_cost_max = cumulative_max
|
||||
else:
|
||||
self.bom_cost_max = None
|
||||
|
||||
if save:
|
||||
self.save()
|
||||
|
||||
def update_purchase_cost(self, save=True):
|
||||
"""Recalculate historical purchase cost for the referenced Part instance.
|
||||
|
||||
Purchase history only takes into account "completed" purchase orders.
|
||||
"""
|
||||
|
||||
# Find all line items for completed orders which reference this part
|
||||
line_items = OrderModels.PurchaseOrderLineItem.objects.filter(
|
||||
order__status=PurchaseOrderStatus.COMPLETE,
|
||||
received__gt=0,
|
||||
part__part=self.part,
|
||||
)
|
||||
|
||||
# Exclude line items which do not have an associated price
|
||||
line_items = line_items.exclude(purchase_price=None)
|
||||
|
||||
purchase_min = None
|
||||
purchase_max = None
|
||||
|
||||
for line in line_items:
|
||||
|
||||
if line.purchase_price is None:
|
||||
continue
|
||||
|
||||
# Take supplier part pack size into account
|
||||
purchase_cost = self.convert(line.purchase_price / line.part.pack_size)
|
||||
|
||||
if purchase_cost is None:
|
||||
continue
|
||||
|
||||
if purchase_min is None or purchase_cost < purchase_min:
|
||||
purchase_min = purchase_cost
|
||||
|
||||
if purchase_max is None or purchase_cost > purchase_max:
|
||||
purchase_max = purchase_cost
|
||||
|
||||
self.purchase_cost_min = purchase_min
|
||||
self.purchase_cost_max = purchase_max
|
||||
|
||||
if save:
|
||||
self.save()
|
||||
|
||||
def update_internal_cost(self, save=True):
|
||||
"""Recalculate internal cost for the referenced Part instance"""
|
||||
|
||||
min_int_cost = None
|
||||
max_int_cost = None
|
||||
|
||||
if InvenTreeSetting.get_setting('PART_INTERNAL_PRICE', False, cache=False):
|
||||
# Only calculate internal pricing if internal pricing is enabled
|
||||
for pb in self.part.internalpricebreaks.all():
|
||||
cost = self.convert(pb.price)
|
||||
|
||||
if cost is None:
|
||||
# Ignore if cost could not be converted for some reason
|
||||
continue
|
||||
|
||||
if min_int_cost is None or cost < min_int_cost:
|
||||
min_int_cost = cost
|
||||
|
||||
if max_int_cost is None or cost > max_int_cost:
|
||||
max_int_cost = cost
|
||||
|
||||
self.internal_cost_min = min_int_cost
|
||||
self.internal_cost_max = max_int_cost
|
||||
|
||||
if save:
|
||||
self.save()
|
||||
|
||||
def update_supplier_cost(self, save=True):
|
||||
"""Recalculate supplier cost for the referenced Part instance.
|
||||
|
||||
- The limits are simply the lower and upper bounds of available SupplierPriceBreaks
|
||||
- We do not take "quantity" into account here
|
||||
"""
|
||||
|
||||
min_sup_cost = None
|
||||
max_sup_cost = None
|
||||
|
||||
if self.part.purchaseable:
|
||||
|
||||
# Iterate through each available SupplierPart instance
|
||||
for sp in self.part.supplier_parts.all():
|
||||
|
||||
# Iterate through each available SupplierPriceBreak instance
|
||||
for pb in sp.pricebreaks.all():
|
||||
|
||||
if pb.price is None:
|
||||
continue
|
||||
|
||||
# Ensure we take supplier part pack size into account
|
||||
cost = self.convert(pb.price / sp.pack_size)
|
||||
|
||||
if cost is None:
|
||||
continue
|
||||
|
||||
if min_sup_cost is None or cost < min_sup_cost:
|
||||
min_sup_cost = cost
|
||||
|
||||
if max_sup_cost is None or cost > max_sup_cost:
|
||||
max_sup_cost = cost
|
||||
|
||||
self.supplier_price_min = min_sup_cost
|
||||
self.supplier_price_max = max_sup_cost
|
||||
|
||||
if save:
|
||||
self.save()
|
||||
|
||||
def update_variant_cost(self, save=True):
|
||||
"""Update variant cost values.
|
||||
|
||||
Here we track the min/max costs of any variant parts.
|
||||
"""
|
||||
|
||||
variant_min = None
|
||||
variant_max = None
|
||||
|
||||
if self.part.is_template:
|
||||
variants = self.part.get_descendants(include_self=False)
|
||||
|
||||
for v in variants:
|
||||
v_min = self.convert(v.pricing.overall_min)
|
||||
v_max = self.convert(v.pricing.overall_max)
|
||||
|
||||
if v_min is not None:
|
||||
if variant_min is None or v_min < variant_min:
|
||||
variant_min = v_min
|
||||
|
||||
if v_max is not None:
|
||||
if variant_max is None or v_max > variant_max:
|
||||
variant_max = v_max
|
||||
|
||||
self.variant_cost_min = variant_min
|
||||
self.variant_cost_max = variant_max
|
||||
|
||||
if save:
|
||||
self.save()
|
||||
|
||||
def update_overall_cost(self):
|
||||
"""Update overall cost values.
|
||||
|
||||
Here we simply take the minimum / maximum values of the other calculated fields.
|
||||
"""
|
||||
|
||||
overall_min = None
|
||||
overall_max = None
|
||||
|
||||
# Calculate overall minimum cost
|
||||
for cost in [
|
||||
self.bom_cost_min,
|
||||
self.purchase_cost_min,
|
||||
self.internal_cost_min,
|
||||
self.supplier_price_min,
|
||||
self.variant_cost_min,
|
||||
]:
|
||||
if cost is None:
|
||||
continue
|
||||
|
||||
# Ensure we are working in a common currency
|
||||
cost = self.convert(cost)
|
||||
|
||||
if overall_min is None or cost < overall_min:
|
||||
overall_min = cost
|
||||
|
||||
# Calculate overall maximum cost
|
||||
for cost in [
|
||||
self.bom_cost_max,
|
||||
self.purchase_cost_max,
|
||||
self.internal_cost_max,
|
||||
self.supplier_price_max,
|
||||
self.variant_cost_max,
|
||||
]:
|
||||
if cost is None:
|
||||
continue
|
||||
|
||||
# Ensure we are working in a common currency
|
||||
cost = self.convert(cost)
|
||||
|
||||
if overall_max is None or cost > overall_max:
|
||||
overall_max = cost
|
||||
|
||||
if InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False, cache=False):
|
||||
# Check if internal pricing should override other pricing
|
||||
if self.internal_cost_min is not None:
|
||||
overall_min = self.internal_cost_min
|
||||
|
||||
if self.internal_cost_max is not None:
|
||||
overall_max = self.internal_cost_max
|
||||
|
||||
self.overall_min = overall_min
|
||||
self.overall_max = overall_max
|
||||
|
||||
def update_sale_cost(self, save=True):
|
||||
"""Recalculate sale cost data"""
|
||||
|
||||
# Iterate through the sell price breaks
|
||||
min_sell_price = None
|
||||
max_sell_price = None
|
||||
|
||||
for pb in self.part.salepricebreaks.all():
|
||||
|
||||
cost = self.convert(pb.price)
|
||||
|
||||
if cost is None:
|
||||
continue
|
||||
|
||||
if min_sell_price is None or cost < min_sell_price:
|
||||
min_sell_price = cost
|
||||
|
||||
if max_sell_price is None or cost > max_sell_price:
|
||||
max_sell_price = cost
|
||||
|
||||
# Record min/max values
|
||||
self.sale_price_min = min_sell_price
|
||||
self.sale_price_max = max_sell_price
|
||||
|
||||
min_sell_history = None
|
||||
max_sell_history = None
|
||||
|
||||
# Find all line items for shipped sales orders which reference this part
|
||||
line_items = OrderModels.SalesOrderLineItem.objects.filter(
|
||||
order__status=SalesOrderStatus.SHIPPED,
|
||||
part=self.part
|
||||
)
|
||||
|
||||
# Exclude line items which do not have associated pricing data
|
||||
line_items = line_items.exclude(sale_price=None)
|
||||
|
||||
for line in line_items:
|
||||
|
||||
cost = self.convert(line.sale_price)
|
||||
|
||||
if cost is None:
|
||||
continue
|
||||
|
||||
if min_sell_history is None or cost < min_sell_history:
|
||||
min_sell_history = cost
|
||||
|
||||
if max_sell_history is None or cost > max_sell_history:
|
||||
max_sell_history = cost
|
||||
|
||||
self.sale_history_min = min_sell_history
|
||||
self.sale_history_max = max_sell_history
|
||||
|
||||
if save:
|
||||
self.save()
|
||||
|
||||
currency = models.CharField(
|
||||
default=currency_code_default,
|
||||
max_length=10,
|
||||
verbose_name=_('Currency'),
|
||||
help_text=_('Currency used to cache pricing calculations'),
|
||||
choices=common.settings.currency_code_mappings(),
|
||||
)
|
||||
|
||||
updated = models.DateTimeField(
|
||||
verbose_name=_('Updated'),
|
||||
help_text=_('Timestamp of last pricing update'),
|
||||
auto_now=True
|
||||
)
|
||||
|
||||
scheduled_for_update = models.BooleanField(
|
||||
default=False,
|
||||
)
|
||||
|
||||
part = models.OneToOneField(
|
||||
Part,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='pricing_data',
|
||||
verbose_name=_('Part'),
|
||||
)
|
||||
|
||||
bom_cost_min = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Minimum BOM Cost'),
|
||||
help_text=_('Minimum cost of component parts')
|
||||
)
|
||||
|
||||
bom_cost_max = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Maximum BOM Cost'),
|
||||
help_text=_('Maximum cost of component parts'),
|
||||
)
|
||||
|
||||
purchase_cost_min = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Minimum Purchase Cost'),
|
||||
help_text=_('Minimum historical purchase cost'),
|
||||
)
|
||||
|
||||
purchase_cost_max = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Maximum Purchase Cost'),
|
||||
help_text=_('Maximum historical purchase cost'),
|
||||
)
|
||||
|
||||
internal_cost_min = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Minimum Internal Price'),
|
||||
help_text=_('Minimum cost based on internal price breaks'),
|
||||
)
|
||||
|
||||
internal_cost_max = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Maximum Internal Price'),
|
||||
help_text=_('Maximum cost based on internal price breaks'),
|
||||
)
|
||||
|
||||
supplier_price_min = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Minimum Supplier Price'),
|
||||
help_text=_('Minimum price of part from external suppliers'),
|
||||
)
|
||||
|
||||
supplier_price_max = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Maximum Supplier Price'),
|
||||
help_text=_('Maximum price of part from external suppliers'),
|
||||
)
|
||||
|
||||
variant_cost_min = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Minimum Variant Cost'),
|
||||
help_text=_('Calculated minimum cost of variant parts'),
|
||||
)
|
||||
|
||||
variant_cost_max = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Maximum Variant Cost'),
|
||||
help_text=_('Calculated maximum cost of variant parts'),
|
||||
)
|
||||
|
||||
overall_min = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Minimum Cost'),
|
||||
help_text=_('Calculated overall minimum cost'),
|
||||
)
|
||||
|
||||
overall_max = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Maximum Cost'),
|
||||
help_text=_('Calculated overall maximum cost'),
|
||||
)
|
||||
|
||||
sale_price_min = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Minimum Sale Price'),
|
||||
help_text=_('Minimum sale price based on price breaks'),
|
||||
)
|
||||
|
||||
sale_price_max = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Maximum Sale Price'),
|
||||
help_text=_('Maximum sale price based on price breaks'),
|
||||
)
|
||||
|
||||
sale_history_min = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Minimum Sale Cost'),
|
||||
help_text=_('Minimum historical sale price'),
|
||||
)
|
||||
|
||||
sale_history_max = InvenTree.fields.InvenTreeModelMoneyField(
|
||||
null=True, blank=True,
|
||||
verbose_name=_('Maximum Sale Cost'),
|
||||
help_text=_('Maximum historical sale price'),
|
||||
)
|
||||
|
||||
|
||||
class PartAttachment(InvenTreeAttachment):
|
||||
"""Model for storing file attachments against a Part object."""
|
||||
|
||||
@ -2886,7 +3484,7 @@ class BomItem(DataImportMixin, models.Model):
|
||||
def price_range(self, internal=False):
|
||||
"""Return the price-range for this BOM item."""
|
||||
# get internal price setting
|
||||
use_internal = common.models.InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
|
||||
use_internal = common.models.InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False, cache=False)
|
||||
prange = self.sub_part.get_price_range(self.quantity, internal=use_internal and internal)
|
||||
|
||||
if prange is None:
|
||||
@ -2904,6 +3502,28 @@ class BomItem(DataImportMixin, models.Model):
|
||||
return "{pmin} to {pmax}".format(pmin=pmin, pmax=pmax)
|
||||
|
||||
|
||||
@receiver(post_save, sender=BomItem, dispatch_uid='post_save_bom_item')
|
||||
@receiver(post_save, sender=PartSellPriceBreak, dispatch_uid='post_save_sale_price_break')
|
||||
@receiver(post_save, sender=PartInternalPriceBreak, dispatch_uid='post_save_internal_price_break')
|
||||
def update_pricing_after_edit(sender, instance, created, **kwargs):
|
||||
"""Callback function when a part price break is created or updated"""
|
||||
|
||||
# Update part pricing *unless* we are importing data
|
||||
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
|
||||
instance.part.pricing.schedule_for_update()
|
||||
|
||||
|
||||
@receiver(post_delete, sender=BomItem, dispatch_uid='post_delete_bom_item')
|
||||
@receiver(post_delete, sender=PartSellPriceBreak, dispatch_uid='post_delete_sale_price_break')
|
||||
@receiver(post_delete, sender=PartInternalPriceBreak, dispatch_uid='post_delete_internal_price_break')
|
||||
def update_pricing_after_delete(sender, instance, **kwargs):
|
||||
"""Callback function when a part price break is deleted"""
|
||||
|
||||
# Update part pricing *unless* we are importing data
|
||||
if InvenTree.ready.canAppAccessDatabase() and not InvenTree.ready.isImportingData():
|
||||
instance.part.pricing.schedule_for_update()
|
||||
|
||||
|
||||
class BomItemSubstitute(models.Model):
|
||||
"""A BomItemSubstitute provides a specification for alternative parts, which can be used in a bill of materials.
|
||||
|
||||
|
Reference in New Issue
Block a user