mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-18 13: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:
@ -1,57 +0,0 @@
|
||||
{% extends "panel.html" %}
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% block label %}currencies{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Currency Settings" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tbody>
|
||||
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-globe" %}
|
||||
|
||||
<tr>
|
||||
<td></td>
|
||||
<th>{% trans "Base Currency" %}</th>
|
||||
<th>{{ base_currency }}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<th colspan='4'>{% trans "Exchange Rates" %}</th>
|
||||
</tr>
|
||||
{% for rate in rates %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>{{ rate.value }}</td>
|
||||
<td>{{ rate.currency }}</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>
|
||||
{% trans "Last Update" %}
|
||||
</th>
|
||||
<td colspan="3">
|
||||
{% if rates_updated %}
|
||||
{{ rates_updated }}
|
||||
{% else %}
|
||||
<em>{% trans "Never" %}</em>
|
||||
{% endif %}
|
||||
<form action='{% url "settings-currencies-refresh" %}' method='post'>
|
||||
<div id='refresh-rates-form'>
|
||||
{% csrf_token %}
|
||||
<button type='submit' id='update-rates' class='btn btn-primary float-right'>{% trans "Update Now" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
@ -15,9 +15,6 @@
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_EDIT_IPN" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_NAME_FORMAT" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_HISTORY" icon="fa-history" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_IN_FORMS" icon="fa-dollar-sign" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_IN_BOM" icon="fa-dollar-sign" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_RELATED" icon="fa-random" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_CREATE_INITIAL" icon="fa-boxes" %}
|
||||
<tr><td colspan='5'></td></tr>
|
||||
@ -34,9 +31,6 @@
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_COPY_TESTS" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_CATEGORY_PARAMETERS" %}
|
||||
<tr><td colspan='5'></td></tr>
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_INTERNAL_PRICE" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_BOM_USE_INTERNAL_PRICE" %}
|
||||
<tr><td colspan='5'></td></tr>
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_CATEGORY_DEFAULT_ICON" icon="fa-icons" %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
74
InvenTree/templates/InvenTree/settings/pricing.html
Normal file
74
InvenTree/templates/InvenTree/settings/pricing.html
Normal file
@ -0,0 +1,74 @@
|
||||
{% extends "panel.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block label %}pricing{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Pricing Settings" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block panel_content %}
|
||||
<div class='panel-content'>
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tbody>
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_INTERNAL_PRICE" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_BOM_USE_INTERNAL_PRICE" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PRICING_DECIMAL_PLACES" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PRICING_UPDATE_DAYS" icon='fa-calendar-alt' %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class='panel-heading'>
|
||||
<div class='d-flex flex-wrap'>
|
||||
<h4>{% trans "Currency Settings" %}</h4>
|
||||
{% include "spacer.html" %}
|
||||
<div class='btn-group' role='group'>
|
||||
<form action='{% url "settings-currencies-refresh" %}' method='post'>
|
||||
<div id='refresh-rates-form'>
|
||||
{% csrf_token %}
|
||||
<button type='submit' id='update-rates' class='btn btn-primary float-right'>{% trans "Update Now" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='panel-content'>
|
||||
{% if rates_updated %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% trans "Last Update" %} - {{ rates_updated }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% trans "Last Update" %} - {% trans "Never" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tbody>
|
||||
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-globe" %}
|
||||
|
||||
<tr>
|
||||
<td></td>
|
||||
<th>{% trans "Base Currency" %}</th>
|
||||
<th>{{ base_currency }}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<th>{% trans "Exchange Rates" %}</th>
|
||||
<th>{% trans "Currency" %}</th>
|
||||
<th>{% trans "Rate" %}</th>
|
||||
</tr>
|
||||
{% for rate in rates %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>{{ rate.currency }}</td>
|
||||
<td>{{ rate.value }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock panel_content %}
|
@ -32,10 +32,10 @@
|
||||
{% include "InvenTree/settings/global.html" %}
|
||||
{% include "InvenTree/settings/login.html" %}
|
||||
{% include "InvenTree/settings/barcode.html" %}
|
||||
{% include "InvenTree/settings/currencies.html" %}
|
||||
{% include "InvenTree/settings/label.html" %}
|
||||
{% include "InvenTree/settings/report.html" %}
|
||||
{% include "InvenTree/settings/part.html" %}
|
||||
{% include "InvenTree/settings/pricing.html" %}
|
||||
{% include "InvenTree/settings/category.html" %}
|
||||
{% include "InvenTree/settings/stock.html" %}
|
||||
{% include "InvenTree/settings/build.html" %}
|
||||
|
@ -32,8 +32,8 @@
|
||||
{% include "sidebar_item.html" with label='login' text=text icon="fa-fingerprint" %}
|
||||
{% trans "Barcode Support" as text %}
|
||||
{% include "sidebar_item.html" with label='barcodes' text=text icon="fa-qrcode" %}
|
||||
{% trans "Currencies" as text %}
|
||||
{% include "sidebar_item.html" with label='currencies' text=text icon="fa-dollar-sign" %}
|
||||
{% trans "Pricing" as text %}
|
||||
{% include "sidebar_item.html" with label='pricing' text=text icon="fa-dollar-sign" %}
|
||||
{% trans "Label Printing" as text %}
|
||||
{% include "sidebar_item.html" with label='labels' text=text icon='fa-tag' %}
|
||||
{% trans "Reporting" as text %}
|
||||
|
@ -154,6 +154,7 @@
|
||||
<script defer type='text/javascript' src="{% i18n_static 'barcode.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'bom.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'build.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'charts.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'company.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'filters.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'forms.js' %}"></script>
|
||||
@ -167,6 +168,7 @@
|
||||
<script defer type='text/javascript' src="{% i18n_static 'search.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'stock.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'plugin.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'pricing.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'news.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'tables.js' %}"></script>
|
||||
<script defer type='text/javascript' src="{% i18n_static 'table_filters.js' %}"></script>
|
||||
|
@ -353,12 +353,25 @@ function exportBom(part_id, options={}) {
|
||||
help_text: '{% trans "Include part supplier data in exported BOM" %}',
|
||||
type: 'boolean',
|
||||
value: inventreeLoad('bom-export-supplier_data', false),
|
||||
},
|
||||
pricing_data: {
|
||||
label: '{% trans "Include Pricing Data" %}',
|
||||
help_text: '{% trans "Include part pricing data in exported BOM" %}',
|
||||
type: 'boolean',
|
||||
value: inventreeLoad('bom-export-pricing_data', false),
|
||||
}
|
||||
},
|
||||
onSubmit: function(fields, opts) {
|
||||
|
||||
// Extract values from the form
|
||||
var field_names = ['format', 'cascade', 'levels', 'parameter_data', 'stock_data', 'manufacturer_data', 'supplier_data'];
|
||||
var field_names = [
|
||||
'format', 'cascade', 'levels',
|
||||
'parameter_data',
|
||||
'stock_data',
|
||||
'manufacturer_data',
|
||||
'supplier_data',
|
||||
'pricing_data',
|
||||
];
|
||||
|
||||
var url = `/part/${part_id}/bom-download/?`;
|
||||
|
||||
@ -750,11 +763,6 @@ function loadBomTable(table, options={}) {
|
||||
ordering: 'name',
|
||||
};
|
||||
|
||||
// Do we show part pricing in the BOM table?
|
||||
var show_pricing = global_settings.PART_SHOW_PRICE_IN_BOM;
|
||||
|
||||
params.include_pricing = show_pricing == true;
|
||||
|
||||
if (options.part_detail) {
|
||||
params.part_detail = true;
|
||||
}
|
||||
@ -905,6 +913,7 @@ function loadBomTable(table, options={}) {
|
||||
title: '{% trans "Quantity" %}',
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
formatter: function(value, row) {
|
||||
var text = value;
|
||||
|
||||
@ -958,53 +967,6 @@ function loadBomTable(table, options={}) {
|
||||
}
|
||||
});
|
||||
|
||||
cols.push({
|
||||
field: 'available_stock',
|
||||
title: '{% trans "Available" %}',
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
|
||||
var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
|
||||
|
||||
// Calculate total "available" (unallocated) quantity
|
||||
var substitute_stock = row.available_substitute_stock || 0;
|
||||
var variant_stock = row.allow_variants ? (row.available_variant_stock || 0) : 0;
|
||||
|
||||
var available_stock = availableQuantity(row);
|
||||
|
||||
var text = `${available_stock}`;
|
||||
|
||||
if (row.sub_part_detail && row.sub_part_detail.units) {
|
||||
text += ` <small>${row.sub_part_detail.units}</small>`;
|
||||
}
|
||||
|
||||
if (available_stock <= 0) {
|
||||
text += `<span class='fas fa-times-circle icon-red float-right' title='{% trans "No Stock Available" %}'></span>`;
|
||||
} else {
|
||||
var extra = '';
|
||||
|
||||
if ((substitute_stock > 0) && (variant_stock > 0)) {
|
||||
extra = '{% trans "Includes variant and substitute stock" %}';
|
||||
} else if (variant_stock > 0) {
|
||||
extra = '{% trans "Includes variant stock" %}';
|
||||
} else if (substitute_stock > 0) {
|
||||
extra = '{% trans "Includes substitute stock" %}';
|
||||
}
|
||||
|
||||
if (extra) {
|
||||
text += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (row.on_order && row.on_order > 0) {
|
||||
text += `<span class='fas fa-shopping-cart float-right' title='{% trans "On Order" %}: ${row.on_order}'></span>`;
|
||||
}
|
||||
|
||||
return renderLink(text, url);
|
||||
}
|
||||
});
|
||||
|
||||
cols.push({
|
||||
field: 'substitutes',
|
||||
title: '{% trans "Substitutes" %}',
|
||||
@ -1065,34 +1027,137 @@ function loadBomTable(table, options={}) {
|
||||
}
|
||||
});
|
||||
|
||||
if (show_pricing) {
|
||||
cols.push({
|
||||
field: 'purchase_price_range',
|
||||
title: '{% trans "Purchase Price Range" %}',
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
});
|
||||
cols.push({
|
||||
field: 'pricing',
|
||||
title: '{% trans "Price Range" %}',
|
||||
sortable: true,
|
||||
sorter: function(valA, valB, rowA, rowB) {
|
||||
var a = rowA.pricing_min || rowA.pricing_max;
|
||||
var b = rowB.pricing_min || rowB.pricing_max;
|
||||
|
||||
cols.push({
|
||||
field: 'purchase_price_avg',
|
||||
title: '{% trans "Purchase Price Average" %}',
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
});
|
||||
if (a != null) {
|
||||
a = parseFloat(a) * rowA.quantity;
|
||||
}
|
||||
|
||||
cols.push({
|
||||
field: 'price_range',
|
||||
title: '{% trans "Supplier Cost" %}',
|
||||
sortable: true,
|
||||
formatter: function(value) {
|
||||
if (value) {
|
||||
return value;
|
||||
if (b != null) {
|
||||
b = parseFloat(b) * rowB.quantity;
|
||||
}
|
||||
|
||||
return (a > b) ? 1 : -1;
|
||||
},
|
||||
formatter: function(value, row) {
|
||||
|
||||
return formatPriceRange(
|
||||
row.pricing_min,
|
||||
row.pricing_max,
|
||||
{
|
||||
quantity: row.quantity
|
||||
}
|
||||
);
|
||||
},
|
||||
footerFormatter: function(data) {
|
||||
// Display overall price range the "footer" of the price_range column
|
||||
|
||||
var min_price = 0;
|
||||
var max_price = 0;
|
||||
|
||||
var any_pricing = false;
|
||||
var complete_pricing = true;
|
||||
|
||||
for (var idx = 0; idx < data.length; idx++) {
|
||||
|
||||
var row = data[idx];
|
||||
|
||||
// No pricing data available for this row
|
||||
if (row.pricing_min == null && row.pricing_max == null) {
|
||||
complete_pricing = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// At this point, we have at least *some* information
|
||||
any_pricing = true;
|
||||
|
||||
// Extract min/max values for this row
|
||||
var row_min = row.pricing_min || row.pricing_max;
|
||||
var row_max = row.pricing_max || row.pricing_min;
|
||||
|
||||
min_price += parseFloat(row_min) * row.quantity;
|
||||
max_price += parseFloat(row_max) * row.quantity;
|
||||
}
|
||||
|
||||
if (any_pricing) {
|
||||
var html = formatCurrency(min_price) + ' - ' + formatCurrency(max_price);
|
||||
|
||||
if (complete_pricing) {
|
||||
html += makeIconBadge(
|
||||
'fa-check-circle icon-green',
|
||||
'{% trans "BOM pricing is complete" %}',
|
||||
);
|
||||
} else {
|
||||
return `<span class='warning-msg'>{% trans 'No supplier pricing available' %}</span>`;
|
||||
html += makeIconBadge(
|
||||
'fa-exclamation-circle icon-yellow',
|
||||
'{% trans "BOM pricing is incomplete" %}',
|
||||
);
|
||||
}
|
||||
|
||||
return html;
|
||||
|
||||
} else {
|
||||
var html = '<em>{% trans "No pricing available" %}</em>';
|
||||
html += makeIconBadge('fa-times-circle icon-red');
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
cols.push({
|
||||
field: 'available_stock',
|
||||
title: '{% trans "Available" %}',
|
||||
searchable: false,
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
|
||||
var url = `/part/${row.sub_part_detail.pk}/?display=part-stock`;
|
||||
|
||||
// Calculate total "available" (unallocated) quantity
|
||||
var substitute_stock = row.available_substitute_stock || 0;
|
||||
var variant_stock = row.allow_variants ? (row.available_variant_stock || 0) : 0;
|
||||
|
||||
var available_stock = availableQuantity(row);
|
||||
|
||||
var text = `${available_stock}`;
|
||||
|
||||
if (row.sub_part_detail && row.sub_part_detail.units) {
|
||||
text += ` <small>${row.sub_part_detail.units}</small>`;
|
||||
}
|
||||
|
||||
if (available_stock <= 0) {
|
||||
text += `<span class='fas fa-times-circle icon-red float-right' title='{% trans "No Stock Available" %}'></span>`;
|
||||
} else {
|
||||
var extra = '';
|
||||
|
||||
if ((substitute_stock > 0) && (variant_stock > 0)) {
|
||||
extra = '{% trans "Includes variant and substitute stock" %}';
|
||||
} else if (variant_stock > 0) {
|
||||
extra = '{% trans "Includes variant stock" %}';
|
||||
} else if (substitute_stock > 0) {
|
||||
extra = '{% trans "Includes substitute stock" %}';
|
||||
}
|
||||
|
||||
if (extra) {
|
||||
text += `<span title='${extra}' class='fas fa-info-circle float-right icon-blue'></span>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (row.on_order && row.on_order > 0) {
|
||||
text += `<span class='fas fa-shopping-cart float-right' title='{% trans "On Order" %}: ${row.on_order}'></span>`;
|
||||
}
|
||||
|
||||
return renderLink(text, url);
|
||||
}
|
||||
});
|
||||
|
||||
cols.push(
|
||||
{
|
||||
@ -1216,7 +1281,6 @@ function loadBomTable(table, options={}) {
|
||||
{
|
||||
part: part_pk,
|
||||
sub_part_detail: true,
|
||||
include_pricing: show_pricing == true,
|
||||
},
|
||||
{
|
||||
success: function(response) {
|
||||
@ -1434,8 +1498,7 @@ function loadUsedInTable(table, part_id, options={}) {
|
||||
|
||||
params.uses = part_id;
|
||||
params.part_detail = true;
|
||||
params.sub_part_detail = true,
|
||||
params.show_pricing = global_settings.PART_SHOW_PRICE_IN_BOM;
|
||||
params.sub_part_detail = true;
|
||||
|
||||
var filters = {};
|
||||
|
||||
|
75
InvenTree/templates/js/translated/charts.js
Normal file
75
InvenTree/templates/js/translated/charts.js
Normal file
@ -0,0 +1,75 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
/* globals
|
||||
*/
|
||||
|
||||
/* exported
|
||||
loadBarChart,
|
||||
loadDoughnutChart,
|
||||
loadLineChart,
|
||||
randomColor,
|
||||
*/
|
||||
|
||||
|
||||
/* Generate a random color */
|
||||
function randomColor() {
|
||||
return '#' + (Math.random().toString(16) + '0000000').slice(2, 8);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Load a simple bar chart
|
||||
*/
|
||||
function loadBarChart(context, data) {
|
||||
return new Chart(context, {
|
||||
type: 'bar',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Load a simple doughnut chart
|
||||
*/
|
||||
function loadDoughnutChart(context, data) {
|
||||
return new Chart(context, {
|
||||
type: 'doughnut',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Load a simple line chart
|
||||
*/
|
||||
function loadLineChart(context, data) {
|
||||
return new Chart(context, {
|
||||
type: 'line',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {position: 'bottom'},
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
@ -22,6 +22,7 @@
|
||||
loadManufacturerPartTable,
|
||||
loadManufacturerPartParameterTable,
|
||||
loadSupplierPartTable,
|
||||
loadSupplierPriceBreakTable,
|
||||
*/
|
||||
|
||||
|
||||
@ -1092,3 +1093,97 @@ function loadSupplierPartTable(table, url, options) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Load a table of supplier price break data
|
||||
*/
|
||||
function loadSupplierPriceBreakTable(options={}) {
|
||||
|
||||
var table = options.table || $('#price-break-table');
|
||||
|
||||
// Setup button callbacks once table is loaded
|
||||
function setupCallbacks() {
|
||||
table.find('.button-price-break-delete').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`/api/company/price-break/${pk}/`, {
|
||||
method: 'DELETE',
|
||||
title: '{% trans "Delete Price Break" %}',
|
||||
onSuccess: function() {
|
||||
table.bootstrapTable('refresh');
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
table.find('.button-price-break-edit').click(function() {
|
||||
var pk = $(this).attr('pk');
|
||||
|
||||
constructForm(`/api/company/price-break/${pk}/`, {
|
||||
fields: {
|
||||
quantity: {},
|
||||
price: {},
|
||||
price_currency: {},
|
||||
},
|
||||
title: '{% trans "Edit Price Break" %}',
|
||||
onSuccess: function() {
|
||||
table.bootstrapTable('refresh');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setupFilterList('supplierpricebreak', table, '#filter-list-supplierpricebreak');
|
||||
|
||||
table.inventreeTable({
|
||||
name: 'buypricebreaks',
|
||||
url: '{% url "api-part-supplier-price-list" %}',
|
||||
queryParams: {
|
||||
part: options.part,
|
||||
},
|
||||
formatNoMatches: function() {
|
||||
return '{% trans "No price break information found" %}';
|
||||
},
|
||||
onPostBody: function() {
|
||||
setupCallbacks();
|
||||
},
|
||||
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) {
|
||||
return formatCurrency(value, {
|
||||
currency: row.price_currency
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'updated',
|
||||
title: '{% trans "Last updated" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
var html = renderDate(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;
|
||||
}
|
||||
},
|
||||
]
|
||||
});
|
||||
}
|
||||
|
@ -4,7 +4,9 @@
|
||||
blankImage,
|
||||
deleteButton,
|
||||
editButton,
|
||||
formatCurrency,
|
||||
formatDecimal,
|
||||
formatPriceRange,
|
||||
imageHoverIcon,
|
||||
makeIconBadge,
|
||||
makeIconButton,
|
||||
@ -38,6 +40,75 @@ function deleteButton(url, text='{% trans "Delete" %}') {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* format currency (money) value based on current settings
|
||||
*
|
||||
* Options:
|
||||
* - currency: Currency code (uses default value if none provided)
|
||||
* - locale: Locale specified (uses default value if none provided)
|
||||
* - digits: Maximum number of significant digits (default = 10)
|
||||
*/
|
||||
function formatCurrency(value, options={}) {
|
||||
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var digits = options.digits || global_settings.PRICING_DECIMAL_PLACES || 6;
|
||||
|
||||
// Strip out any trailing zeros, etc
|
||||
value = formatDecimal(value, digits);
|
||||
|
||||
// Extract default currency information
|
||||
var currency = options.currency || global_settings.INVENTREE_DEFAULT_CURRENCY || 'USD';
|
||||
|
||||
// Exctract locale information
|
||||
var locale = options.locale || navigator.language || 'en-US';
|
||||
|
||||
|
||||
var formatter = new Intl.NumberFormat(
|
||||
locale,
|
||||
{
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
maximumSignificantDigits: digits,
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(value);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Format a range of prices
|
||||
*/
|
||||
function formatPriceRange(price_min, price_max, options={}) {
|
||||
|
||||
var p_min = price_min || price_max;
|
||||
var p_max = price_max || price_min;
|
||||
|
||||
var quantity = options.quantity || 1;
|
||||
|
||||
if (p_min == null && p_max == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
p_min = parseFloat(p_min) * quantity;
|
||||
p_max = parseFloat(p_max) * quantity;
|
||||
|
||||
var output = '';
|
||||
|
||||
output += formatCurrency(p_min, options);
|
||||
|
||||
if (p_min != p_max) {
|
||||
output += ' - ';
|
||||
output += formatCurrency(p_max, options);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Ensure a string does not exceed a maximum length.
|
||||
* Useful for displaying long strings in tables,
|
||||
|
@ -798,30 +798,64 @@ function poLineItemFields(options={}) {
|
||||
// If the pack_size != 1, add a note to the field
|
||||
var pack_size = 1;
|
||||
var units = '';
|
||||
var supplier_part_id = value;
|
||||
var quantity = getFormFieldValue('quantity', {}, opts);
|
||||
|
||||
// Remove any existing note fields
|
||||
$(opts.modal).find('#info-pack-size').remove();
|
||||
|
||||
if (value != null) {
|
||||
inventreeGet(`/api/company/part/${value}/`,
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Request information about the particular supplier part
|
||||
inventreeGet(`/api/company/part/${value}/`,
|
||||
{
|
||||
part_detail: true,
|
||||
},
|
||||
{
|
||||
success: function(response) {
|
||||
// Extract information from the returned query
|
||||
pack_size = response.pack_size || 1;
|
||||
units = response.part_detail.units || '';
|
||||
},
|
||||
}
|
||||
).then(function() {
|
||||
// Update pack size information
|
||||
if (pack_size != 1) {
|
||||
var txt = `<span class='fas fa-info-circle icon-blue'></span> {% trans "Pack Quantity" %}: ${pack_size} ${units}`;
|
||||
$(opts.modal).find('#hint_id_quantity').after(`<div class='form-info-message' id='info-pack-size'>${txt}</div>`);
|
||||
}
|
||||
}).then(function() {
|
||||
// Update pricing data (if available)
|
||||
inventreeGet(
|
||||
'{% url "api-part-supplier-price-list" %}',
|
||||
{
|
||||
part_detail: true,
|
||||
part: supplier_part_id,
|
||||
ordering: 'quantity',
|
||||
},
|
||||
{
|
||||
success: function(response) {
|
||||
// Extract information from the returned query
|
||||
pack_size = response.pack_size || 1;
|
||||
units = response.part_detail.units || '';
|
||||
},
|
||||
}
|
||||
).then(function() {
|
||||
// Returned prices are in increasing order of quantity
|
||||
if (response.length > 0) {
|
||||
var idx = 0;
|
||||
|
||||
if (pack_size != 1) {
|
||||
var txt = `<span class='fas fa-info-circle icon-blue'></span> {% trans "Pack Quantity" %}: ${pack_size} ${units}`;
|
||||
$(opts.modal).find('#hint_id_quantity').after(`<div class='form-info-message' id='info-pack-size'>${txt}</div>`);
|
||||
for (var idx = 0; idx < response.length; idx++) {
|
||||
if (response[idx].quantity > quantity) {
|
||||
break;
|
||||
}
|
||||
|
||||
index = idx;
|
||||
}
|
||||
|
||||
// Update price and currency data in the form
|
||||
updateFieldValue('purchase_price', response[index].price, {}, opts);
|
||||
updateFieldValue('purchase_price_currency', response[index].price_currency, {}, opts);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
secondary: {
|
||||
method: 'POST',
|
||||
@ -2305,14 +2339,9 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
field: 'purchase_price',
|
||||
title: '{% trans "Unit Price" %}',
|
||||
formatter: function(value, row) {
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: row.purchase_price_currency
|
||||
}
|
||||
);
|
||||
return formatter.format(row.purchase_price);
|
||||
return formatCurrency(row.purchase_price, {
|
||||
currency: row.purchase_price_currency,
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2320,14 +2349,9 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
sortable: true,
|
||||
title: '{% trans "Total Price" %}',
|
||||
formatter: function(value, row) {
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: row.purchase_price_currency
|
||||
}
|
||||
);
|
||||
return formatter.format(row.purchase_price * row.quantity);
|
||||
return formatCurrency(row.purchase_price * row.quantity, {
|
||||
currency: row.purchase_price_currency
|
||||
});
|
||||
},
|
||||
footerFormatter: function(data) {
|
||||
var total = data.map(function(row) {
|
||||
@ -2338,15 +2362,9 @@ function loadPurchaseOrderLineItemTable(table, options={}) {
|
||||
|
||||
var currency = (data.slice(-1)[0] && data.slice(-1)[0].purchase_price_currency) || 'USD';
|
||||
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: currency
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(total);
|
||||
return formatCurrency(total, {
|
||||
currency: currency
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2508,15 +2526,9 @@ function loadPurchaseOrderExtraLineTable(table, options={}) {
|
||||
field: 'price',
|
||||
title: '{% trans "Unit Price" %}',
|
||||
formatter: function(value, row) {
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: row.price_currency
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(row.price);
|
||||
return formatCurrency(row.price, {
|
||||
currency: row.price_currency,
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2524,15 +2536,9 @@ function loadPurchaseOrderExtraLineTable(table, options={}) {
|
||||
sortable: true,
|
||||
title: '{% trans "Total Price" %}',
|
||||
formatter: function(value, row) {
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: row.price_currency
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(row.price * row.quantity);
|
||||
return formatCurrency(row.price * row.quantity, {
|
||||
currency: row.price_currency,
|
||||
});
|
||||
},
|
||||
footerFormatter: function(data) {
|
||||
var total = data.map(function(row) {
|
||||
@ -2543,15 +2549,9 @@ function loadPurchaseOrderExtraLineTable(table, options={}) {
|
||||
|
||||
var currency = (data.slice(-1)[0] && data.slice(-1)[0].price_currency) || 'USD';
|
||||
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: currency
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(total);
|
||||
return formatCurrency(total, {
|
||||
currency: currency,
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
@ -3732,7 +3732,7 @@ function reloadTotal() {
|
||||
{},
|
||||
{
|
||||
success: function(data) {
|
||||
$(TotalPriceRef).html(data.total_price_string);
|
||||
$(TotalPriceRef).html(formatCurrency(data.price, {currency: data.price_currency}));
|
||||
}
|
||||
}
|
||||
);
|
||||
@ -3851,15 +3851,9 @@ function loadSalesOrderLineItemTable(table, options={}) {
|
||||
field: 'sale_price',
|
||||
title: '{% trans "Unit Price" %}',
|
||||
formatter: function(value, row) {
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: row.sale_price_currency
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(row.sale_price);
|
||||
return formatCurrency(row.sale_price, {
|
||||
currency: row.sale_price_currency
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -3867,15 +3861,9 @@ function loadSalesOrderLineItemTable(table, options={}) {
|
||||
sortable: true,
|
||||
title: '{% trans "Total Price" %}',
|
||||
formatter: function(value, row) {
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: row.sale_price_currency
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(row.sale_price * row.quantity);
|
||||
return formatCurrency(row.sale_price * row.quantity, {
|
||||
currency: row.sale_price_currency,
|
||||
});
|
||||
},
|
||||
footerFormatter: function(data) {
|
||||
var total = data.map(function(row) {
|
||||
@ -3886,15 +3874,9 @@ function loadSalesOrderLineItemTable(table, options={}) {
|
||||
|
||||
var currency = (data.slice(-1)[0] && data.slice(-1)[0].sale_price_currency) || 'USD';
|
||||
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: currency
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(total);
|
||||
return formatCurrency(total, {
|
||||
currency: currency,
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -4360,15 +4342,9 @@ function loadSalesOrderExtraLineTable(table, options={}) {
|
||||
field: 'price',
|
||||
title: '{% trans "Unit Price" %}',
|
||||
formatter: function(value, row) {
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: row.price_currency
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(row.price);
|
||||
return formatCurrency(row.price, {
|
||||
currency: row.price_currency,
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -4376,15 +4352,9 @@ function loadSalesOrderExtraLineTable(table, options={}) {
|
||||
sortable: true,
|
||||
title: '{% trans "Total Price" %}',
|
||||
formatter: function(value, row) {
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: row.price_currency
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(row.price * row.quantity);
|
||||
return formatCurrency(row.price * row.quantity, {
|
||||
currency: row.price_currency,
|
||||
});
|
||||
},
|
||||
footerFormatter: function(data) {
|
||||
var total = data.map(function(row) {
|
||||
@ -4395,15 +4365,9 @@ function loadSalesOrderExtraLineTable(table, options={}) {
|
||||
|
||||
var currency = (data.slice(-1)[0] && data.slice(-1)[0].price_currency) || 'USD';
|
||||
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: currency
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(total);
|
||||
return formatCurrency(total, {
|
||||
currency: currency,
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
|
@ -26,8 +26,6 @@
|
||||
duplicatePart,
|
||||
editCategory,
|
||||
editPart,
|
||||
initPriceBreakSet,
|
||||
loadBomChart,
|
||||
loadParametricPartTable,
|
||||
loadPartCategoryTable,
|
||||
loadPartParameterTable,
|
||||
@ -37,9 +35,7 @@
|
||||
loadPartSchedulingChart,
|
||||
loadPartVariantTable,
|
||||
loadRelatedPartsTable,
|
||||
loadSellPricingChart,
|
||||
loadSimplePartTable,
|
||||
loadStockPricingChart,
|
||||
partStockLabel,
|
||||
toggleStar,
|
||||
validateBom,
|
||||
@ -781,6 +777,16 @@ function loadPartVariantTable(table, partId, options={}) {
|
||||
|
||||
return renderLink(text, `/part/${row.pk}/?display=part-stock`);
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'price_range',
|
||||
title: '{% trans "Price Range" %}',
|
||||
formatter: function(value, row) {
|
||||
return formatPriceRange(
|
||||
row.pricing_min,
|
||||
row.pricing_max,
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@ -813,6 +819,9 @@ function loadPartVariantTable(table, partId, options={}) {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Load a "simplified" part table without filtering
|
||||
*/
|
||||
function loadSimplePartTable(table, url, options={}) {
|
||||
|
||||
options.disableFilters = true;
|
||||
@ -1121,15 +1130,9 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
|
||||
title: '{% trans "Price" %}',
|
||||
switchable: true,
|
||||
formatter: function(value, row) {
|
||||
var formatter = new Intl.NumberFormat(
|
||||
'en-US',
|
||||
{
|
||||
style: 'currency',
|
||||
currency: row.purchase_price_currency,
|
||||
}
|
||||
);
|
||||
|
||||
return formatter.format(row.purchase_price);
|
||||
return formatCurrency(row.purchase_price, {
|
||||
currency: row.purchase_price_currency,
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -1392,19 +1395,19 @@ function partGridTile(part) {
|
||||
}
|
||||
|
||||
|
||||
/* Load part listing data into specified table.
|
||||
*
|
||||
* Args:
|
||||
* - table: HTML reference to the table
|
||||
* - url: Base URL for API query
|
||||
* - options: object containing following (optional) fields
|
||||
* checkbox: Show the checkbox column
|
||||
* query: extra query params for API request
|
||||
* buttons: If provided, link buttons to selection status of this table
|
||||
* disableFilters: If true, disable custom filters
|
||||
* actions: Provide a callback function to construct an "actions" column
|
||||
*/
|
||||
function loadPartTable(table, url, options={}) {
|
||||
/* Load part listing data into specified table.
|
||||
*
|
||||
* Args:
|
||||
* - table: HTML reference to the table
|
||||
* - url: Base URL for API query
|
||||
* - options: object containing following (optional) fields
|
||||
* checkbox: Show the checkbox column
|
||||
* query: extra query params for API request
|
||||
* buttons: If provided, link buttons to selection status of this table
|
||||
* disableFilters: If true, disable custom filters
|
||||
* actions: Provide a callback function to construct an "actions" column
|
||||
*/
|
||||
|
||||
// Ensure category detail is included
|
||||
options.params['category_detail'] = true;
|
||||
@ -1444,21 +1447,11 @@ function loadPartTable(table, url, options={}) {
|
||||
});
|
||||
}
|
||||
|
||||
col = {
|
||||
field: 'IPN',
|
||||
title: '{% trans "IPN" %}',
|
||||
};
|
||||
|
||||
if (!options.params.ordering) {
|
||||
col['sortable'] = true;
|
||||
}
|
||||
|
||||
columns.push(col);
|
||||
|
||||
col = {
|
||||
columns.push({
|
||||
field: 'name',
|
||||
title: '{% trans "Part" %}',
|
||||
switchable: false,
|
||||
sortable: !options.params.ordering,
|
||||
formatter: function(value, row) {
|
||||
|
||||
var name = shortenString(row.full_name);
|
||||
@ -1469,13 +1462,13 @@ function loadPartTable(table, url, options={}) {
|
||||
|
||||
return withTitle(display, row.full_name);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
if (!options.params.ordering) {
|
||||
col['sortable'] = true;
|
||||
}
|
||||
|
||||
columns.push(col);
|
||||
columns.push({
|
||||
field: 'IPN',
|
||||
title: '{% trans "IPN" %}',
|
||||
sortable: !options.params.ordering
|
||||
});
|
||||
|
||||
columns.push({
|
||||
field: 'description',
|
||||
@ -1582,6 +1575,19 @@ function loadPartTable(table, url, options={}) {
|
||||
|
||||
columns.push(col);
|
||||
|
||||
// Pricing information
|
||||
columns.push({
|
||||
field: 'pricing_min',
|
||||
sortable: false,
|
||||
title: '{% trans "Price Range" %}',
|
||||
formatter: function(value, row) {
|
||||
return formatPriceRange(
|
||||
row.pricing_min,
|
||||
row.pricing_max
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
columns.push({
|
||||
field: 'link',
|
||||
title: '{% trans "Link" %}',
|
||||
@ -1838,7 +1844,7 @@ function loadPartCategoryTable(table, options) {
|
||||
filters[key] = params[key];
|
||||
}
|
||||
|
||||
setupFilterList(filterKey, table, filterListElement);
|
||||
setupFilterList(filterKey, table, filterListElement, {download: true});
|
||||
|
||||
// Function to request sub-category items
|
||||
function requestSubItems(parent_pk) {
|
||||
@ -2176,173 +2182,6 @@ function loadPartTestTemplateTable(table, options) {
|
||||
}
|
||||
|
||||
|
||||
function loadPriceBreakTable(table, options) {
|
||||
/*
|
||||
* Load PriceBreak table.
|
||||
*/
|
||||
|
||||
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,
|
||||
method: 'get',
|
||||
formatNoMatches: function() {
|
||||
return `{% trans "No ${human_name} information found" %}`;
|
||||
},
|
||||
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 exists
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
chart = loadLineChart(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 = value;
|
||||
|
||||
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 loadLineChart(context, data) {
|
||||
return new Chart(context, {
|
||||
type: 'line',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {position: 'bottom'},
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 a chart which displays projected scheduling information for a particular part.
|
||||
* This takes into account:
|
||||
@ -2719,115 +2558,3 @@ function loadPartSchedulingChart(canvas_id, part_id) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function loadStockPricingChart(context, data) {
|
||||
return new Chart(context, {
|
||||
type: 'bar',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {legend: {position: 'bottom'}},
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
grid: {display: false},
|
||||
title: {
|
||||
display: true,
|
||||
text: '{% trans "Single Price" %}'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
grid: {display: false},
|
||||
titel: {
|
||||
display: true,
|
||||
text: '{% trans "Quantity" %}',
|
||||
position: 'right'
|
||||
}
|
||||
},
|
||||
y2: {
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
grid: {display: false},
|
||||
title: {
|
||||
display: true,
|
||||
text: '{% trans "Single Price Difference" %}'
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function loadBomChart(context, data) {
|
||||
return new Chart(context, {
|
||||
type: 'doughnut',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
},
|
||||
scales: {
|
||||
xAxes: [
|
||||
{
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
autoSkip: false,
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function loadSellPricingChart(context, data) {
|
||||
return new Chart(context, {
|
||||
type: 'line',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: '{% trans "Unit Price" %}',
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
titel: {
|
||||
display: true,
|
||||
text: '{% trans "Quantity" %}',
|
||||
position: 'right'
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
791
InvenTree/templates/js/translated/pricing.js
Normal file
791
InvenTree/templates/js/translated/pricing.js
Normal file
@ -0,0 +1,791 @@
|
||||
{% 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;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
@ -1980,17 +1980,16 @@ function loadStockTable(table, options) {
|
||||
|
||||
columns.push(col);
|
||||
|
||||
col = {
|
||||
field: 'purchase_price_string',
|
||||
columns.push({
|
||||
field: 'purchase_price',
|
||||
title: '{% trans "Purchase Price" %}',
|
||||
};
|
||||
|
||||
if (!options.params.ordering) {
|
||||
col.sortable = true;
|
||||
col.sortName = 'purchase_price';
|
||||
}
|
||||
|
||||
columns.push(col);
|
||||
sortable: false,
|
||||
formatter: function(value, row) {
|
||||
return formatCurrency(value, {
|
||||
currency: row.purchase_price_currency,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
columns.push({
|
||||
field: 'packaging',
|
||||
@ -2268,7 +2267,7 @@ function loadStockLocationTable(table, options) {
|
||||
original[k] = params[k];
|
||||
}
|
||||
|
||||
setupFilterList(filterKey, table, filterListElement);
|
||||
setupFilterList(filterKey, table, filterListElement, {download: true});
|
||||
|
||||
for (var key in params) {
|
||||
filters[key] = params[key];
|
||||
|
@ -87,6 +87,10 @@ function getAvailableTableFilters(tableKey) {
|
||||
type: 'bool',
|
||||
title: '{% trans "Consumable" %}',
|
||||
},
|
||||
has_pricing: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Has Pricing" %}',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -498,7 +502,11 @@ function getAvailableTableFilters(tableKey) {
|
||||
virtual: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Virtual" %}',
|
||||
}
|
||||
},
|
||||
has_pricing: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Has Pricing" %}',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
8
InvenTree/templates/price_data.html
Normal file
8
InvenTree/templates/price_data.html
Normal file
@ -0,0 +1,8 @@
|
||||
{% load inventree_extras %}
|
||||
{% load i18n %}
|
||||
|
||||
{% if price %}
|
||||
{% render_currency price %}
|
||||
{% else %}
|
||||
<em>{% trans "No data" %}</em>
|
||||
{% endif %}
|
Reference in New Issue
Block a user