2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-30 04:26:44 +00:00
Oliver 06266b48af
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 cb26003b92536f67fba640d84aa2a6596d18b4e0)

* 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
2022-11-14 15:58:22 +11:00

792 lines
24 KiB
JavaScript

{% load i18n %}
{% load inventree_extras %}
/* Functions for retrieving and displaying pricing data */
/* globals
*/
/* exported
loadBomPricingChart,
loadPartSupplierPricingTable,
initPriceBreakSet,
loadPriceBreakTable,
loadPurchasePriceHistoryTable,
loadSalesPriceHistoryTable,
loadVariantPricingChart,
*/
/*
* Load BOM pricing chart
*/
function loadBomPricingChart(options={}) {
var part = options.part;
if (!part) {
console.error('No part provided to loadPurchasePriceHistoryTable');
return;
}
var table = options.table || $('#bom-pricing-table');
var chartElement = options.table || $('#bom-pricing-chart');
var chart = null;
options.params = options.params || {};
options.params.part = part;
options.params.sub_part_detail = true;
options.params.ordering = 'name';
options.params.has_pricing = true;
table.inventreeTable({
url: '{% url "api-bom-list" %}',
name: 'bompricingtable',
queryParams: options.params,
original: options.params,
paginationVAlign: 'bottom',
pageSize: 10,
search: false,
showColumns: false,
formatNoMatches: function() {
return '{% trans "No BOM data available" %}';
},
onLoadSuccess: function(data) {
// Construct BOM pricing chart
// Note here that we use stacked bars to denote "min" and "max" costs
// Ignore any entries without pricing information
data = data.filter((x) => x.pricing_min != null || x.pricing_max != null);
// Sort in decreasing order of "maximum price"
data = data.sort(function(a, b) {
var pa = parseFloat(a.quantity * (a.pricing_max || a.pricing_min));
var pb = parseFloat(b.quantity * (b.pricing_max || b.pricing_min));
return pb - pa;
});
var graphLabels = Array.from(data, (x) => x.sub_part_detail.name);
var minValues = Array.from(data, (x) => x.quantity * (x.pricing_min || x.pricing_max));
var maxValues = Array.from(data, (x) => x.quantity * (x.pricing_max || x.pricing_min));
if (chart) {
chart.destroy();
}
// Generate colors
var colors = Array.from(data, (x) => randomColor());
chart = loadDoughnutChart(chartElement, {
labels: graphLabels,
datasets: [
{
label: '{% trans "Maximum Price" %}',
data: maxValues,
backgroundColor: colors,
},
{
label: '{% trans "Minimum Price" %}',
data: minValues,
backgroundColor: colors,
},
]
});
},
columns: [
{
field: 'sub_part',
title: '{% trans "Part" %}',
sortable: true,
formatter: function(value, row) {
var url = `/part/${row.sub_part}/`;
var part = row.sub_part_detail;
return imageHoverIcon(part.thumbnail) + renderLink(part.full_name, url);
},
},
{
field: 'quantity',
title: '{% trans "Quantity" %}',
sortable: true,
},
{
field: 'reference',
title: '{% trans "Reference" %}',
sortable: true,
},
{
field: 'pricing',
title: '{% trans "Price Range" %}',
sortable: false,
formatter: function(value, row) {
var min_price = row.pricing_min;
var max_price = row.pricing_max;
if (min_price == null && max_price == null) {
// No pricing information available at all
return null;
}
// If pricing is the same, return single value
if (min_price == max_price) {
return formatCurrency(min_price * row.quantity);
}
var output = '';
if (min_price != null) {
output += formatCurrency(min_price * row.quantity);
if (max_price != null) {
output += ' - ';
}
}
if (max_price != null) {
output += formatCurrency(max_price * row.quantity);
}
return output;
}
}
]
});
}
/*
* Load a table displaying complete supplier pricing information for a given part
*/
function loadPartSupplierPricingTable(options={}) {
var part = options.part;
if (!part) {
console.error('No part provided to loadPurchasePriceHistoryTable');
return;
}
var table = options.table || $('#part-supplier-pricing-table');
var chartElement = options.chart || $('#part-supplier-pricing-chart');
var chart = null;
options.params = options.params || {};
options.params.base_part = part;
options.params.supplier_detail = true;
options.params.part_detail = true;
table.inventreeTable({
url: '{% url "api-part-supplier-price-list" %}',
name: 'partsupplierprice',
queryParams: options.params,
original: options.params,
paginationVAlign: 'bottom',
pageSize: 10,
pageList: null,
search: false,
showColumns: false,
formatNoMatches: function() {
return '{% trans "No supplier pricing data available" %}';
},
onLoadSuccess: function(data) {
// Update supplier pricing chart
// Only allow values with pricing information
data = data.filter((x) => x.price != null);
// Sort in increasing order of quantity
data = data.sort((a, b) => (a.quantity - b.quantity));
var graphLabels = Array.from(data, (x) => (`${x.part_detail.SKU} - {% trans "Quantity" %} ${x.quantity}`));
var graphValues = Array.from(data, (x) => (x.price / x.part_detail.pack_size));
if (chart) {
chart.destroy();
}
chart = loadBarChart(chartElement, {
labels: graphLabels,
datasets: [
{
label: '{% trans "Supplier Pricing" %}',
data: graphValues,
backgroundColor: 'rgba(255, 206, 86, 0.2)',
borderColor: 'rgb(255, 206, 86)',
stepped: true,
fill: true,
}
]
});
},
columns: [
{
field: 'supplier',
title: '{% trans "Supplier" %}',
formatter: function(value, row) {
var html = '';
html += imageHoverIcon(row.supplier_detail.image);
html += renderLink(row.supplier_detail.name, `/company/${row.supplier}/`);
return html;
}
},
{
field: 'sku',
title: '{% trans "SKU" %}',
sortable: true,
formatter: function(value, row) {
return renderLink(
row.part_detail.SKU,
`/supplier-part/${row.part}/`
);
}
},
{
sortable: true,
field: 'quantity',
title: '{% trans "Quantity" %}',
},
{
sortable: true,
field: 'price',
title: '{% trans "Unit Price" %}',
formatter: function(value, row) {
if (row.price == null) {
return '-';
}
// Convert to unit pricing
var unit_price = row.price / row.part_detail.pack_size;
var html = formatCurrency(unit_price, {
currency: row.price_currency
});
if (row.updated != null) {
html += `<span class='badge badge-right rounded-pill bg-dark'>${renderDate(row.updated)}</span>`;
}
return html;
}
}
]
});
}
/*
* Load PriceBreak table.
*/
function loadPriceBreakTable(table, options={}) {
var name = options.name || 'pricebreak';
var human_name = options.human_name || 'price break';
var linkedGraph = options.linkedGraph || null;
var chart = null;
table.inventreeTable({
name: name,
search: false,
showColumns: false,
paginationVAlign: 'bottom',
pageSize: 10,
method: 'get',
formatNoMatches: function() {
return `{% trans "No price break data available" %}`;
},
queryParams: {
part: options.part
},
url: options.url,
onLoadSuccess: function(tableData) {
if (linkedGraph) {
// sort array
tableData = tableData.sort((a, b) => (a.quantity - b.quantity));
// split up for graph definition
var graphLabels = Array.from(tableData, (x) => (x.quantity));
var graphData = Array.from(tableData, (x) => (x.price));
// Destroy chart if it already exists
if (chart) {
chart.destroy();
}
chart = loadBarChart(linkedGraph, {
labels: graphLabels,
datasets: [
{
label: '{% trans "Unit Price" %}',
data: graphData,
backgroundColor: 'rgba(255, 206, 86, 0.2)',
borderColor: 'rgb(255, 206, 86)',
stepped: true,
fill: true,
},
],
});
}
},
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) {
var html = formatCurrency(value, {currency: row.price_currency});
html += `<div class='btn-group float-right' role='group'>`;
html += makeIconButton('fa-edit icon-blue', `button-${name}-edit`, row.pk, `{% trans "Edit ${human_name}" %}`);
html += makeIconButton('fa-trash-alt icon-red', `button-${name}-delete`, row.pk, `{% trans "Delete ${human_name}" %}`);
html += `</div>`;
return html;
}
},
]
});
}
function initPriceBreakSet(table, options) {
var part_id = options.part_id;
var pb_human_name = options.pb_human_name;
var pb_url_slug = options.pb_url_slug;
var pb_url = options.pb_url;
var pb_new_btn = options.pb_new_btn;
var pb_new_url = options.pb_new_url;
var linkedGraph = options.linkedGraph || null;
loadPriceBreakTable(
table,
{
name: pb_url_slug,
human_name: pb_human_name,
url: pb_url,
linkedGraph: linkedGraph,
part: part_id,
}
);
function reloadPriceBreakTable() {
table.bootstrapTable('refresh');
}
pb_new_btn.click(function() {
constructForm(pb_new_url, {
fields: {
part: {
hidden: true,
value: part_id,
},
quantity: {},
price: {},
price_currency: {},
},
method: 'POST',
title: '{% trans "Add Price Break" %}',
onSuccess: reloadPriceBreakTable,
});
});
table.on('click', `.button-${pb_url_slug}-delete`, function() {
var pk = $(this).attr('pk');
constructForm(`${pb_url}${pk}/`, {
method: 'DELETE',
title: '{% trans "Delete Price Break" %}',
onSuccess: reloadPriceBreakTable,
});
});
table.on('click', `.button-${pb_url_slug}-edit`, function() {
var pk = $(this).attr('pk');
constructForm(`${pb_url}${pk}/`, {
fields: {
quantity: {},
price: {},
price_currency: {},
},
title: '{% trans "Edit Price Break" %}',
onSuccess: reloadPriceBreakTable,
});
});
}
/*
* Load purchase price history for the given part
*/
function loadPurchasePriceHistoryTable(options={}) {
var part = options.part;
if (!part) {
console.error('No part provided to loadPurchasePriceHistoryTable');
return;
}
var table = options.table || $('#part-purchase-history-table');
var chartElement = options.chart || $('#part-purchase-history-chart');
var chart = null;
options.params = options.params || {};
options.params.base_part = part;
options.params.part_detail = true;
options.params.order_detail = true;
options.params.has_pricing = true;
// Purchase order must be 'COMPLETE'
options.params.order_status = {{ PurchaseOrderStatus.COMPLETE }};
table.inventreeTable({
url: '{% url "api-po-line-list" %}',
name: 'partpurchasehistory',
queryParams: options.params,
original: options.params,
paginationVAlign: 'bottom',
pageSize: 10,
search: false,
showColumns: false,
formatNoMatches: function() {
return '{% trans "No purchase history data available" %}';
},
onLoadSuccess: function(data) {
// Update purchase price history chart
// Only allow values with pricing information
data = data.filter((x) => x.purchase_price != null);
// Sort in increasing date order
data = data.sort((a, b) => (a.order_detail.complete_date - b.order_detail.complete_date));
var graphLabels = Array.from(data, (x) => (`${x.order_detail.reference} - ${x.order_detail.complete_date}`));
var graphValues = Array.from(data, (x) => (x.purchase_price / x.supplier_part_detail.pack_size));
if (chart) {
chart.destroy();
}
chart = loadBarChart(chartElement, {
labels: graphLabels,
datasets: [
{
label: '{% trans "Purchase Price History" %}',
data: graphValues,
backgroundColor: 'rgba(255, 206, 86, 0.2)',
borderColor: 'rgb(255, 206, 86)',
stepped: true,
fill: true,
}
]
});
},
columns: [
{
field: 'order',
title: '{% trans "Purchase Order" %}',
sortable: true,
formatter: function(value, row) {
var order = row.order_detail;
if (!order) {
return '-';
}
var html = '';
var supplier = row.supplier_part_detail.supplier_detail;
html += imageHoverIcon(supplier.thumbnail || supplier.image);
html += renderLink(order.reference, `/order/purchase-order/${order.pk}/`);
html += ' - ';
html += renderLink(supplier.name, `/company/${supplier.pk}/`);
return html;
}
},
{
field: 'order_detail.complete_date',
title: '{% trans "Date" %}',
sortable: true,
formatter: function(value) {
return renderDate(value);
}
},
{
field: 'purchase_price',
title: '{% trans "Unit Price" %}',
sortable: true,
formatter: function(value, row) {
if (row.purchase_price == null) {
return '-';
}
return formatCurrency(row.purchase_price / row.supplier_part_detail.pack_size, {
currency: row.purchase_price_currency
});
}
},
]
});
}
/*
* Load sales price history for the given part
*/
function loadSalesPriceHistoryTable(options={}) {
var part = options.part;
if (!part) {
console.error('No part provided to loadPurchasePriceHistoryTable');
return;
}
var table = options.table || $('#part-sales-history-table');
var chartElement = options.chart || $('#part-sales-history-chart');
var chart = null;
options.params = options.params || {};
options.params.part = part;
options.params.order_detail = true;
options.params.customer_detail = true;
// Only return results which have pricing information
options.params.has_pricing = true;
// Sales order must be 'SHIPPED'
options.params.order_status = {{ SalesOrderStatus.SHIPPED }};
table.inventreeTable({
url: '{% url "api-so-line-list" %}',
name: 'partsaleshistory',
queryParams: options.params,
original: options.params,
paginationVAlign: 'bottom',
pageSize: 10,
search: false,
showColumns: false,
formatNoMatches: function() {
return '{% trans "No sales history data available" %}';
},
onLoadSuccess: function(data) {
// Update sales price history chart
// Ignore any orders which have not shipped
data = data.filter((x) => x.order_detail.shipment_date != null);
// Sort in increasing date order
data = data.sort((a, b) => (a.order_detail.shipment_date - b.order_detail.shipment_date));
var graphLabels = Array.from(data, (x) => x.order_detail.shipment_date);
var graphValues = Array.from(data, (x) => x.sale_price);
if (chart) {
chart.destroy();
}
chart = loadBarChart(chartElement, {
labels: graphLabels,
datasets: [
{
label: '{% trans "Sale Price History" %}',
data: graphValues,
backgroundColor: 'rgba(255, 206, 86, 0.2)',
borderColor: 'rgb(255, 206, 86)',
stepped: true,
fill: true,
}
]
});
},
columns: [
{
field: 'order',
title: '{% trans "Sales Order" %}',
formatter: function(value, row) {
var order = row.order_detail;
var customer = row.customer_detail;
if (!order) {
return '-';
}
var html = '';
html += imageHoverIcon(customer.thumbnail || customer.image);
html += renderLink(order.reference, `/order/sales-order/${order.pk}/`);
html += ' - ';
html += renderLink(customer.name, `/company/${customer.pk}/`);
return html;
}
},
{
field: 'shipment_date',
title: '{% trans "Date" %}',
formatter: function(value, row) {
return renderDate(row.order_detail.shipment_date);
}
},
{
field: 'sale_price',
title: '{% trans "Sale Price" %}',
formatter: function(value, row) {
return formatCurrency(value, {
currency: row.sale_price_currency
});
}
}
]
});
}
/*
* Load chart and table for part variant pricing
*/
function loadVariantPricingChart(options={}) {
var part = options.part;
if (!part) {
console.error('No part provided to loadPurchasePriceHistoryTable');
return;
}
var table = options.table || $('#variant-pricing-table');
var chartElement = options.chart || $('#variant-pricing-chart');
var chart = null;
options.params = options.params || {};
options.params.ancestor = part;
table.inventreeTable({
url: '{% url "api-part-list" %}',
name: 'variantpricingtable',
queryParams: options.params,
original: options.params,
paginationVAlign: 'bottom',
pageSize: 10,
search: false,
showColumns: false,
formatNoMatches: function() {
return '{% trans "No variant data available" %}';
},
onLoadSuccess: function(data) {
// Construct variant pricing chart
data = data.filter((x) => x.pricing_min != null || x.pricing_max != null);
var graphLabels = Array.from(data, (x) => x.full_name);
var minValues = Array.from(data, (x) => x.pricing_min || x.pricing_max);
var maxValues = Array.from(data, (x) => x.pricing_max || x.pricing_min);
if (chart) {
chart.destroy();
}
chart = loadBarChart(chartElement, {
labels: graphLabels,
datasets: [
{
label: '{% trans "Minimum Price" %}',
data: minValues,
backgroundColor: 'rgba(200, 250, 200, 0.75)',
borderColor: 'rgba(200, 250, 200)',
stepped: true,
fill: true,
},
{
label: '{% trans "Maximum Price" %}',
data: maxValues,
backgroundColor: 'rgba(250, 220, 220, 0.75)',
borderColor: 'rgba(250, 220, 220)',
stepped: true,
fill: true,
}
]
});
},
columns: [
{
field: 'part',
title: '{% trans "Variant Part" %}',
formatter: function(value, row) {
var name = shortenString(row.full_name);
var display = imageHoverIcon(row.thumbnail) + renderLink(name, `/part/${row.pk}/`);
return withTitle(display, row.full_name);
}
},
{
field: 'pricing',
title: '{% trans "Price Range" %}',
formatter: function(value, row) {
var min_price = row.pricing_min;
var max_price = row.pricing_max;
if (min_price == null && max_price == null) {
// No pricing information available at all
return null;
}
// If pricing is the same, return single value
if (min_price == max_price) {
return formatCurrency(min_price);
}
var output = '';
if (min_price != null) {
output += formatCurrency(min_price);
if (max_price != null) {
output += ' - ';
}
}
if (max_price != null) {
output += formatCurrency(max_price);
}
return output;
}
}
]
});
}