2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 20:45:44 +00:00

[Feature] Stocktake reports (#4345)

* Add settings to control upcoming stocktake features

* Adds migration for "cost range" when performing stocktake

* Add cost data to PartStocktakeSerializer

Implement a new custom serializer for currency data type

* Refactor existing currency serializers

* Update stocktake table and forms

* Prevent trailing zeroes in forms

* Calculate cost range when adding manual stocktake entry

* Display interactive chart for part stocktake history

* Ensure chart data are converted to common currency

* Adds new model for building stocktake reports

* Add admin integration for new model

* Adds API endpoint to expose list of stocktake reports available for download

- No ability to edit or delete via API

* Add setting to control automated deletion of old stocktake reports

* Updates for settings page

- Load part stocktake report table
- Refactor function to render a downloadable media file
- Fix bug with forcing files to be downloaded
- Split js code into separate templates
- Make use of onPanelLoad functionalitty

* Fix conflicting migration files

* Adds API endpoint for manual generation of stocktake report

* Offload task to generate new stocktake report

* Adds python function to perform stocktake on a single part instance

* Small bug fixes

* Various tweaks

- Prevent new stocktake models from triggering plugin events when created
- Construct a simple csv dataset

* Generate new report

* Updates for report generation

- Prefetch related data
- Add extra columns
- Keep track of stocktake instances (for saving to database later on)

* Updates:

- Add confirmation message
- Serializer validation checks

* Ensure that background worker is running before manually scheduling a new stocktake report

* Add extra fields to stocktake models

Also move code from part/models.py to part/tasks.py

* Add 'part_count' to PartStocktakeReport table

* Updates for stocktake generation

- remove old performStocktake javascript code
- Now handled by automated server-side calculation
- Generate report for a single part

* Add a new "role" for stocktake

- Allows fine-grained control on viewing / creating / deleting stocktake data
- More in-line with existing permission controls
- Remove STOCKTAKE_OWNER setting

* Add serializer field to limit stocktake report to particular locations

* Use location restriction when generating a stocktake report

* Add UI buttons to perform stocktake for a whole category tree

* Add button to perform stocktake report for a location tree

* Adds a background tasks to handle periodic generation of stocktake reports

- Reports are generated at fixed intervals
- Deletes old reports after certain number of days

* Implement notifications for new stocktake reports

- If manually requested by a user, notify that user
- Cleanup notification table
- Amend PartStocktakeModel for better notification rendering

* Hide buttons on location and category page if stocktake is not enabled

* Cleanup log messages during server start

* Extend functionality of RoleRequired permission mixin

- Allow 'role_required' attribute to be added to an API view
- Useful when using a serializer class that does not have a model defined

* Add boolean option to toggle whether a report will be generated

* Update generateStocktake function

* Improve location filtering

- Don't limit the actual stock items
- Instead, select only parts which exist within a given location tree

* Update API version

* String tweaks

* Fix permissions for PartStocktake API

* More unit testing for stocktake functionality

* QoL fix

* Fix for assigning inherited permissions
This commit is contained in:
Oliver
2023-02-17 11:42:48 +11:00
committed by GitHub
parent e6c9db2ff3
commit 0f445ea6e4
45 changed files with 1700 additions and 713 deletions

View File

@ -0,0 +1,45 @@
{% extends "panel.html" %}
{% load i18n %}
{% block label %}stocktake{% endblock %}
{% block heading %}
{% trans "Stocktake Settings" %}
{% endblock %}
{% block panel_content %}
<div class='panel-content'>
<table class='table table-striped table-condensed'>
<tbody>
{% include "InvenTree/settings/setting.html" with key="STOCKTAKE_ENABLE" icon="fa-clipboard-check" %}
{% include "InvenTree/settings/setting.html" with key="STOCKTAKE_AUTO_DAYS" icon="fa-calendar-alt" %}
{% include "InvenTree/settings/setting.html" with key="STOCKTAKE_DELETE_REPORT_DAYS" icon="fa-trash-alt" %}
</tbody>
</table>
</div>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Stocktake Reports" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% if roles.stocktake.add %}
<button type='button' id='btn-generate-stocktake' class='btn btn-primary float-right'>
<span class='fas fa-clipboard-check'></span> {% trans "Stocktake" %}
</button>
{% endif %}
</div>
</div>
</div>
<div class='panel-content'>
<div id='part-stocktake-report-toolbar'>
<div class='btn-group' role='group'>
{% include "filter_list.html" with id="stocktakereport" %}
</div>
</div>
<table class='table table-striped table-condensed' id='stocktake-report-table' data-toolbar='#part-stocktake-report-toolbar'></table>
</div>
{% endblock panel_content %}

View File

@ -36,8 +36,9 @@
{% include "InvenTree/settings/label.html" %}
{% include "InvenTree/settings/report.html" %}
{% include "InvenTree/settings/part.html" %}
{% include "InvenTree/settings/pricing.html" %}
{% include "InvenTree/settings/part_stocktake.html" %}
{% include "InvenTree/settings/category.html" %}
{% include "InvenTree/settings/pricing.html" %}
{% include "InvenTree/settings/stock.html" %}
{% include "InvenTree/settings/build.html" %}
{% include "InvenTree/settings/po.html" %}
@ -62,426 +63,16 @@
{% block js_ready %}
{{ block.super }}
// Callback for when boolean settings are edited
$('table').find('.boolean-setting').change(function() {
{% include "InvenTree/settings/settings_js.html" %}
var pk = $(this).attr('pk');
var setting = $(this).attr('setting');
var plugin = $(this).attr('plugin');
var user = $(this).attr('user');
var notification = $(this).attr('notification');
var checked = this.checked;
// Global setting by default
var url = `/api/settings/global/${setting}/`;
if (notification) {
url = `/api/settings/notification/${pk}/`;
} else if (plugin) {
url = `/api/plugins/settings/${plugin}/${setting}/`;
} else if (user) {
url = `/api/settings/user/${setting}/`;
}
inventreePut(
url,
{
value: checked.toString(),
},
{
method: 'PATCH',
success: function(data) {
},
error: function(xhr) {
showApiError(xhr, url);
}
}
);
});
// Callback for when non-boolean settings are edited
$('table').find('.btn-edit-setting').click(function() {
var setting = $(this).attr('setting');
var plugin = $(this).attr('plugin');
var is_global = true;
var notification = $(this).attr('notification');
if ($(this).attr('user')){
is_global = false;
}
var title = '';
if (plugin != null) {
title = '{% trans "Edit Plugin Setting" %}';
} else if (notification) {
title = '{% trans "Edit Notification Setting" %}';
setting = $(this).attr('pk');
} else if (is_global) {
title = '{% trans "Edit Global Setting" %}';
} else {
title = '{% trans "Edit User Setting" %}';
}
editSetting(setting, {
plugin: plugin,
global: is_global,
notification: notification,
title: title,
});
});
$("#edit-user").on('click', function() {
launchModalForm(
"{% url 'edit-user' %}",
{
reload: true,
}
);
});
$("#edit-password").on('click', function() {
launchModalForm(
"{% url 'set-password' %}",
{
reload: true,
}
);
});
$('#btn-update-rates').click(function() {
inventreePut(
'{% url "api-currency-refresh" %}',
{},
{
method: 'POST',
success: function(data) {
location.reload();
}
}
);
});
$('#exchange-rate-table').inventreeTable({
url: '{% url "api-currency-exchange" %}',
search: false,
showColumns: false,
sortable: true,
sidePagination: 'client',
onLoadSuccess: function(response) {
var data = response.exchange_rates || {};
var rows = [];
for (var currency in data) {
rows.push({
'currency': currency,
'rate': data[currency],
});
}
$('#exchange-rate-table').bootstrapTable('load', rows);
},
columns: [
{
field: 'currency',
sortable: true,
title: '{% trans "Currency" %}',
},
{
field: 'rate',
sortable: true,
title: '{% trans "Rate" %}',
}
]
});
$('#category-select').select2({
placeholder: '',
width: '100%',
ajax: {
url: '{% url "api-part-category-list" %}',
dataType: 'json',
delay: 250,
cache: false,
data: function(params) {
if (!params.page) {
offset = 0;
} else {
offset = (params.page - 1) * 25;
}
return {
search: params.term,
offset: offset,
limit: 25,
};
},
processResults: function(response) {
var data = [];
var more = false;
if ('count' in response && 'results' in response) {
// Response is paginated
data = response.results;
// Any more data available?
if (response.next) {
more = true;
}
} else {
// Non-paginated response
data = response;
}
// Each 'row' must have the 'id' attribute
for (var idx = 0; idx < data.length; idx++) {
data[idx].id = data[idx].pk;
data[idx].text = data[idx].pathstring;
}
// Ref: https://select2.org/data-sources/formats
var results = {
results: data,
pagination: {
more: more,
}
};
return results;
}
},
});
$('#cat-param-table').inventreeTable({
formatNoMatches: function() { return '{% trans "No category parameter templates found" %}'; },
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'parameter_template_detail.name',
title: '{% trans "Parameter Template" %}',
sortable: 'true',
},
{
field: 'category_detail.pathstring',
title: '{% trans "Category" %}',
},
{
field: 'default_value',
title: '{% trans "Default Value" %}',
sortable: 'true',
formatter: function(value, row, index, field) {
var bEdit = "<button title='{% trans "Edit Template" %}' class='template-edit btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-edit'></span></button>";
var bDel = "<button title='{% trans "Delete Template" %}' class='template-delete btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>";
var html = value
html += "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>";
return html;
}
}
]
});
function loadTemplateTable(pk) {
var query = {};
if (pk) {
query['category'] = pk;
}
// Load the parameter table
$("#cat-param-table").bootstrapTable('refresh', {
query: query,
url: '{% url "api-part-category-parameter-list" %}',
});
}
// Initially load table with *all* categories
loadTemplateTable();
$('body').on('change', '#category-select', function() {
var pk = $(this).val();
loadTemplateTable(pk);
});
$("#new-cat-param").click(function() {
var pk = $('#category-select').val();
constructForm('{% url "api-part-category-parameter-list" %}', {
title: '{% trans "Create Category Parameter Template" %}',
method: 'POST',
fields: {
parameter_template: {},
category: {
icon: 'fa-sitemap',
value: pk,
},
default_value: {},
},
onSuccess: function() {
loadTemplateTable(pk);
}
});
});
$("#cat-param-table").on('click', '.template-edit', function() {
var category = $('#category-select').val();
var pk = $(this).attr('pk');
constructForm(`/api/part/category/parameters/${pk}/`, {
fields: {
parameter_template: {},
category: {
icon: 'fa-sitemap',
},
default_value: {},
},
onSuccess: function() {
loadTemplateTable(pk);
}
});
});
$("#cat-param-table").on('click', '.template-delete', function() {
var category = $('#category-select').val();
var pk = $(this).attr('pk');
var url = `/part/category/${category}/parameters/${pk}/delete/`;
constructForm(`/api/part/category/parameters/${pk}/`, {
method: 'DELETE',
title: '{% trans "Delete Category Parameter Template" %}',
onSuccess: function() {
loadTemplateTable(pk);
}
});
});
$("#param-table").inventreeTable({
url: "{% url 'api-part-parameter-template-list' %}",
queryParams: {
ordering: 'name',
},
formatNoMatches: function() { return '{% trans "No part parameter templates found" %}'; },
columns: [
{
field: 'pk',
title: '{% trans "ID" %}',
visible: false,
switchable: false,
},
{
field: 'name',
title: '{% trans "Name" %}',
sortable: true,
},
{
field: 'units',
title: '{% trans "Units" %}',
sortable: true,
switchable: true,
},
{
field: 'description',
title: '{% trans "Description" %}',
sortable: false,
switchable: true,
},
{
formatter: function(value, row, index, field) {
var bEdit = "<button title='{% trans "Edit Template" %}' class='template-edit btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-edit icon-green'></span></button>";
var bDel = "<button title='{% trans "Delete Template" %}' class='template-delete btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>";
var html = "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>";
return html;
}
}
]
});
$("#new-param").click(function() {
constructForm('{% url "api-part-parameter-template-list" %}', {
fields: {
name: {},
units: {},
description: {},
},
method: 'POST',
title: '{% trans "Create Part Parameter Template" %}',
onSuccess: function() {
$("#param-table").bootstrapTable('refresh');
},
});
});
$("#param-table").on('click', '.template-edit', function() {
var button = $(this);
var pk = button.attr('pk');
constructForm(
`/api/part/parameter/template/${pk}/`,
{
fields: {
name: {},
units: {},
description: {},
},
title: '{% trans "Edit Part Parameter Template" %}',
onSuccess: function() {
$("#param-table").bootstrapTable('refresh');
},
}
);
});
$("#param-table").on('click', '.template-delete', function() {
var button = $(this);
var pk = button.attr('pk');
var html = `
<div class='alert alert-block alert-danger'>
{% trans "Any parameters which reference this template will also be deleted" %}
</div>`;
constructForm(
`/api/part/parameter/template/${pk}/`,
{
method: 'DELETE',
preFormContent: html,
title: '{% trans "Delete Part Parameter Template" %}',
onSuccess: function() {
$("#param-table").bootstrapTable('refresh');
},
}
);
});
$("#import-part").click(function() {
launchModalForm("{% url 'api-part-import' %}?reset", {});
});
{% plugins_enabled as plug %}
{% if plug %}
$("#install-plugin").click(function() {
installPlugin();
});
{% if user.is_staff %}
{% include "InvenTree/settings/settings_staff_js.html" %}
{% plugins_enabled as plug %}
{% if plug %}
$("#install-plugin").click(function() {
installPlugin();
});
{% endif %}
{% endif %}
enableSidebar('settings');

View File

@ -0,0 +1,92 @@
{% load i18n %}
{% load static %}
{% load inventree_extras %}
// Callback for when boolean settings are edited
$('table').find('.boolean-setting').change(function() {
var pk = $(this).attr('pk');
var setting = $(this).attr('setting');
var plugin = $(this).attr('plugin');
var user = $(this).attr('user');
var notification = $(this).attr('notification');
var checked = this.checked;
// Global setting by default
var url = `/api/settings/global/${setting}/`;
if (notification) {
url = `/api/settings/notification/${pk}/`;
} else if (plugin) {
url = `/api/plugins/settings/${plugin}/${setting}/`;
} else if (user) {
url = `/api/settings/user/${setting}/`;
}
inventreePut(
url,
{
value: checked.toString(),
},
{
method: 'PATCH',
success: function(data) {
},
error: function(xhr) {
showApiError(xhr, url);
}
}
);
});
// Callback for when non-boolean settings are edited
$('table').find('.btn-edit-setting').click(function() {
var setting = $(this).attr('setting');
var plugin = $(this).attr('plugin');
var is_global = true;
var notification = $(this).attr('notification');
if ($(this).attr('user')){
is_global = false;
}
var title = '';
if (plugin != null) {
title = '{% trans "Edit Plugin Setting" %}';
} else if (notification) {
title = '{% trans "Edit Notification Setting" %}';
setting = $(this).attr('pk');
} else if (is_global) {
title = '{% trans "Edit Global Setting" %}';
} else {
title = '{% trans "Edit User Setting" %}';
}
editSetting(setting, {
plugin: plugin,
global: is_global,
notification: notification,
title: title,
});
});
$("#edit-user").on('click', function() {
launchModalForm(
"{% url 'edit-user' %}",
{
reload: true,
}
);
});
$("#edit-password").on('click', function() {
launchModalForm(
"{% url 'set-password' %}",
{
reload: true,
}
);
});

View File

@ -0,0 +1,401 @@
{% load i18n %}
{% load static %}
{% load inventree_extras %}
// Javascript for Pricing panel
onPanelLoad('pricing', function() {
$('#btn-update-rates').click(function() {
inventreePut(
'{% url "api-currency-refresh" %}',
{},
{
method: 'POST',
success: function(data) {
location.reload();
}
}
);
});
$('#exchange-rate-table').inventreeTable({
url: '{% url "api-currency-exchange" %}',
search: false,
showColumns: false,
sortable: true,
sidePagination: 'client',
onLoadSuccess: function(response) {
var data = response.exchange_rates || {};
var rows = [];
for (var currency in data) {
rows.push({
'currency': currency,
'rate': data[currency],
});
}
$('#exchange-rate-table').bootstrapTable('load', rows);
},
columns: [
{
field: 'currency',
sortable: true,
title: '{% trans "Currency" %}',
},
{
field: 'rate',
sortable: true,
title: '{% trans "Rate" %}',
}
]
});
});
// Javascript for Part Category panel
onPanelLoad('category', function() {
$('#category-select').select2({
placeholder: '',
width: '100%',
ajax: {
url: '{% url "api-part-category-list" %}',
dataType: 'json',
delay: 250,
cache: false,
data: function(params) {
if (!params.page) {
offset = 0;
} else {
offset = (params.page - 1) * 25;
}
return {
search: params.term,
offset: offset,
limit: 25,
};
},
processResults: function(response) {
var data = [];
var more = false;
if ('count' in response && 'results' in response) {
// Response is paginated
data = response.results;
// Any more data available?
if (response.next) {
more = true;
}
} else {
// Non-paginated response
data = response;
}
// Each 'row' must have the 'id' attribute
for (var idx = 0; idx < data.length; idx++) {
data[idx].id = data[idx].pk;
data[idx].text = data[idx].pathstring;
}
// Ref: https://select2.org/data-sources/formats
var results = {
results: data,
pagination: {
more: more,
}
};
return results;
}
},
});
$('#cat-param-table').inventreeTable({
formatNoMatches: function() { return '{% trans "No category parameter templates found" %}'; },
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'parameter_template_detail.name',
title: '{% trans "Parameter Template" %}',
sortable: 'true',
},
{
field: 'category_detail.pathstring',
title: '{% trans "Category" %}',
},
{
field: 'default_value',
title: '{% trans "Default Value" %}',
sortable: 'true',
formatter: function(value, row, index, field) {
var bEdit = "<button title='{% trans "Edit Template" %}' class='template-edit btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-edit'></span></button>";
var bDel = "<button title='{% trans "Delete Template" %}' class='template-delete btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>";
var html = value
html += "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>";
return html;
}
}
]
});
$("#cat-param-table").on('click', '.template-edit', function() {
var category = $('#category-select').val();
var pk = $(this).attr('pk');
constructForm(`/api/part/category/parameters/${pk}/`, {
fields: {
parameter_template: {},
category: {
icon: 'fa-sitemap',
},
default_value: {},
},
onSuccess: function() {
loadTemplateTable(pk);
}
});
});
$("#cat-param-table").on('click', '.template-delete', function() {
var category = $('#category-select').val();
var pk = $(this).attr('pk');
var url = `/part/category/${category}/parameters/${pk}/delete/`;
constructForm(`/api/part/category/parameters/${pk}/`, {
method: 'DELETE',
title: '{% trans "Delete Category Parameter Template" %}',
onSuccess: function() {
loadTemplateTable(pk);
}
});
});
function loadTemplateTable(pk) {
var query = {};
if (pk) {
query['category'] = pk;
}
// Load the parameter table
$("#cat-param-table").bootstrapTable('refresh', {
query: query,
url: '{% url "api-part-category-parameter-list" %}',
});
}
// Initially load table with *all* categories
loadTemplateTable();
$('body').on('change', '#category-select', function() {
var pk = $(this).val();
loadTemplateTable(pk);
});
$("#new-cat-param").click(function() {
var pk = $('#category-select').val();
constructForm('{% url "api-part-category-parameter-list" %}', {
title: '{% trans "Create Category Parameter Template" %}',
method: 'POST',
fields: {
parameter_template: {},
category: {
icon: 'fa-sitemap',
value: pk,
},
default_value: {},
},
onSuccess: function() {
loadTemplateTable(pk);
}
});
});
});
// Javascript for the Part settings panel
onPanelLoad('parts', function() {
$("#param-table").inventreeTable({
url: "{% url 'api-part-parameter-template-list' %}",
queryParams: {
ordering: 'name',
},
formatNoMatches: function() { return '{% trans "No part parameter templates found" %}'; },
columns: [
{
field: 'pk',
title: '{% trans "ID" %}',
visible: false,
switchable: false,
},
{
field: 'name',
title: '{% trans "Name" %}',
sortable: true,
},
{
field: 'units',
title: '{% trans "Units" %}',
sortable: true,
switchable: true,
},
{
field: 'description',
title: '{% trans "Description" %}',
sortable: false,
switchable: true,
},
{
formatter: function(value, row, index, field) {
var bEdit = "<button title='{% trans "Edit Template" %}' class='template-edit btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-edit icon-green'></span></button>";
var bDel = "<button title='{% trans "Delete Template" %}' class='template-delete btn btn-outline-secondary' type='button' pk='" + row.pk + "'><span class='fas fa-trash-alt icon-red'></span></button>";
var html = "<div class='btn-group float-right' role='group'>" + bEdit + bDel + "</div>";
return html;
}
}
]
});
$("#new-param").click(function() {
constructForm('{% url "api-part-parameter-template-list" %}', {
fields: {
name: {},
units: {},
description: {},
},
method: 'POST',
title: '{% trans "Create Part Parameter Template" %}',
onSuccess: function() {
$("#param-table").bootstrapTable('refresh');
},
});
});
$("#param-table").on('click', '.template-edit', function() {
var button = $(this);
var pk = button.attr('pk');
constructForm(
`/api/part/parameter/template/${pk}/`,
{
fields: {
name: {},
units: {},
description: {},
},
title: '{% trans "Edit Part Parameter Template" %}',
onSuccess: function() {
$("#param-table").bootstrapTable('refresh');
},
}
);
});
$("#param-table").on('click', '.template-delete', function() {
var button = $(this);
var pk = button.attr('pk');
var html = `
<div class='alert alert-block alert-danger'>
{% trans "Any parameters which reference this template will also be deleted" %}
</div>`;
constructForm(
`/api/part/parameter/template/${pk}/`,
{
method: 'DELETE',
preFormContent: html,
title: '{% trans "Delete Part Parameter Template" %}',
onSuccess: function() {
$("#param-table").bootstrapTable('refresh');
},
}
);
});
$("#import-part").click(function() {
launchModalForm("{% url 'api-part-import' %}?reset", {});
});
});
// Javascript for the Stocktake settings panel
onPanelLoad('stocktake', function() {
{% if roles.stocktake.view %}
var table = '#stocktake-report-table';
var filters = loadTableFilters('stocktakereport');
setupFilterList('stocktakereport', $(table), '#filter-list-stocktakereport');
$(table).inventreeTable({
url: '{% url "api-part-stocktake-report-list" %}',
search: false,
queryParams: filters,
name: 'stocktakereport',
showColumns: false,
sidePagination: 'server',
sortable: true,
sortName: 'date',
sortOrder: 'desc',
columns: [
{
field: 'report',
title: '{% trans "Report" %}',
formatter: function(value, row) {
return attachmentLink(value);
}
},
{
field: 'part_count',
title: '{% trans "Part Count" %}',
},
{
field: 'date',
title: '{% trans "Date" %}',
sortable: true,
formatter: function(value, row) {
let html = renderDate(value);
if (row.user_detail) {
html += `<span class='badge bg-dark rounded-pill float-right'>${row.user_detail.username}</span>`;
}
return html;
}
},
]
});
{% endif %}
{% if roles.stocktake.add %}
$('#btn-generate-stocktake').click(function() {
generateStocktakeReport({
part: {},
category: {},
location: {},
generate_report: {},
update_parts: {},
});
});
{% endif %}
});

View File

@ -40,12 +40,14 @@
{% include "sidebar_item.html" with label='labels' text=text icon='fa-tag' %}
{% trans "Reporting" as text %}
{% include "sidebar_item.html" with label='reporting' text=text icon="fa-file-pdf" %}
{% trans "Parts" as text %}
{% include "sidebar_item.html" with label='parts' text=text icon="fa-shapes" %}
{% trans "Categories" as text %}
{% include "sidebar_item.html" with label='category' text=text icon="fa-sitemap" %}
{% trans "Parts" as text %}
{% include "sidebar_item.html" with label='parts' text=text icon="fa-shapes" %}
{% trans "Stock" as text %}
{% include "sidebar_item.html" with label='stock' text=text icon="fa-boxes" %}
{% trans "Stocktake" as text %}
{% include "sidebar_item.html" with label='stocktake' text=text icon="fa-clipboard-check" %}
{% trans "Build Orders" as text %}
{% include "sidebar_item.html" with label='build-order' text=text icon="fa-tools" %}
{% trans "Purchase Orders" as text %}

View File

@ -21,6 +21,7 @@
{% include "InvenTree/settings/setting.html" with key="STOCK_ALLOW_EXPIRED_BUILD" icon="fa-tools" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_OWNERSHIP_CONTROL" icon="fa-users" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_LOCATION_DEFAULT_ICON" icon="fa-icons" %}
</tbody>
</table>
{% endblock %}

View File

@ -6,6 +6,7 @@
*/
/* exported
attachmentLink,
addAttachmentButtonCallbacks,
loadAttachmentTable,
reloadAttachmentTable,
@ -130,6 +131,50 @@ function reloadAttachmentTable() {
}
/*
* Render a link (with icon) to an internal attachment (file)
*/
function attachmentLink(filename) {
if (!filename) {
return null;
}
// Default file icon (if no better choice is found)
let icon = 'fa-file-alt';
let fn = filename.toLowerCase();
// Look for some "known" file types
if (fn.endsWith('.csv')) {
icon = 'fa-file-csv';
} else if (fn.endsWith('.pdf')) {
icon = 'fa-file-pdf';
} else if (fn.endsWith('.xls') || fn.endsWith('.xlsx')) {
icon = 'fa-file-excel';
} else if (fn.endsWith('.doc') || fn.endsWith('.docx')) {
icon = 'fa-file-word';
} else if (fn.endsWith('.zip') || fn.endsWith('.7z')) {
icon = 'fa-file-archive';
} else {
let images = ['.png', '.jpg', '.bmp', '.gif', '.svg', '.tif'];
images.forEach(function(suffix) {
if (fn.endsWith(suffix)) {
icon = 'fa-file-image';
}
});
}
let split = filename.split('/');
fn = split[split.length - 1];
let html = `<span class='fas ${icon}'></span> ${fn}`;
return renderLink(html, filename, {download: true});
}
/* Load a table of attachments against a specific model.
* Note that this is a 'generic' table which is used for multiple attachment model classes
*/
@ -242,36 +287,7 @@ function loadAttachmentTable(url, options) {
formatter: function(value, row) {
if (row.attachment) {
var icon = 'fa-file-alt';
var fn = value.toLowerCase();
if (fn.endsWith('.csv')) {
icon = 'fa-file-csv';
} else if (fn.endsWith('.pdf')) {
icon = 'fa-file-pdf';
} else if (fn.endsWith('.xls') || fn.endsWith('.xlsx')) {
icon = 'fa-file-excel';
} else if (fn.endsWith('.doc') || fn.endsWith('.docx')) {
icon = 'fa-file-word';
} else if (fn.endsWith('.zip') || fn.endsWith('.7z')) {
icon = 'fa-file-archive';
} else {
var images = ['.png', '.jpg', '.bmp', '.gif', '.svg', '.tif'];
images.forEach(function(suffix) {
if (fn.endsWith(suffix)) {
icon = 'fa-file-image';
}
});
}
var split = value.split('/');
var filename = split[split.length - 1];
var html = `<span class='fas ${icon}'></span> ${filename}`;
return renderLink(html, value, {download: true});
return attachmentLink(row.attachment);
} else if (row.link) {
var html = `<span class='fas fa-link'></span> ${row.link}`;
return renderLink(html, row.link);

View File

@ -974,6 +974,10 @@ function updateFieldValue(name, value, field, options) {
}
switch (field.type) {
case 'decimal':
// Strip trailing zeros
el.val(formatDecimal(value));
break;
case 'boolean':
if (value == true || value.toString().toLowerCase() == 'true') {
el.prop('checked');

View File

@ -274,6 +274,10 @@ function renderLink(text, url, options={}) {
extras += ` title="${url}"`;
}
if (options.download) {
extras += ` download`;
}
return `<a href="${url}" ${extras}>${text}</a>`;
}

View File

@ -50,25 +50,16 @@ function loadNotificationTable(table, options={}, enableDelete=false) {
title: '{% trans "Category" %}',
sortable: 'true',
},
{
field: 'target',
title: '{% trans "Item" %}',
sortable: 'true',
formatter: function(value, row, index, field) {
if (value == null) {
return '';
}
var html = `${value.model}: ${value.name}`;
if (value.link ) {
html = `<a href='${value.link}'>${html}</a>`;
}
return html;
}
},
{
field: 'name',
title: '{% trans "Name" %}',
title: '{% trans "Notification" %}',
formatter: function(value, row) {
if (row.target && row.target.link) {
return renderLink(value, row.target.link);
} else {
return value;
}
}
},
{
field: 'message',

View File

@ -27,6 +27,7 @@
duplicatePart,
editCategory,
editPart,
generateStocktakeReport,
loadParametricPartTable,
loadPartCategoryTable,
loadPartParameterTable,
@ -40,7 +41,6 @@
loadSimplePartTable,
partDetail,
partStockLabel,
performStocktake,
toggleStar,
validateBom,
*/
@ -702,133 +702,178 @@ function partDetail(part, options={}) {
/*
* Guide user through "stocktake" process
* Initiate generation of a stocktake report
*/
function performStocktake(partId, options={}) {
function generateStocktakeReport(options={}) {
var part_quantity = 0;
let fields = {
};
var date_threshold = moment().subtract(30, 'days');
// Helper function for formatting a StockItem row
function buildStockItemRow(item) {
var pk = item.pk;
// Part detail
var part = partDetail(item.part_detail, {
thumb: true,
});
// Location detail
var location = locationDetail(item);
// Quantity detail
var quantity = item.quantity;
part_quantity += item.quantity;
if (item.serial && item.quantity == 1) {
quantity = `{% trans "Serial" %}: ${item.serial}`;
}
quantity += stockStatusDisplay(item.status, {classes: 'float-right'});
// Last update
var updated = item.stocktake_date || item.updated;
var update_rendered = renderDate(updated);
if (updated) {
if (moment(updated) < date_threshold) {
update_rendered += `<div class='float-right' title='{% trans "Stock item has not been checked recently" %}'><span class='fas fa-calendar-alt icon-red'></span></div>`;
}
}
// Actions
var actions = `<div class='btn-group float-right' role='group'>`;
// TODO: Future work
// actions += makeIconButton('fa-check-circle icon-green', 'button-line-count', pk, '{% trans "Update item" %}');
// actions += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete item" %}');
actions += `</div>`;
return `
<tr>
<td id='part-${pk}'>${part}</td>
<td id='loc-${pk}'>${location}</td>
<td id='quantity-${pk}'>${quantity}</td>
<td id='updated-${pk}'>${update_rendered}</td>
<td id='actions-${pk}'>${actions}</td>
</tr>`;
if (options.part != null) {
fields.part = options.part;
}
// First, load stock information for the part
inventreeGet(
'{% url "api-stock-list" %}',
if (options.category != null) {
fields.category = options.category;
}
if (options.location != null) {
fields.location = options.location;
}
if (options.generate_report) {
fields.generate_report = options.generate_report;
}
if (options.update_parts) {
fields.update_parts = options.update_parts;
}
let content = `
<div class='alert alert-block alert-info'>
{% trans "Schedule generation of a new stocktake report." %} {% trans "Once complete, the stocktake report will be available for download." %}
</div>
`;
constructForm(
'{% url "api-part-stocktake-report-generate" %}',
{
part: partId,
in_stock: true,
location_detail: true,
part_detail: true,
include_variants: true,
ordering: '-stock',
},
{
success: function(response) {
var html = '';
html += `
<table class='table table-striped table-condensed'>
<thead>
<tr>
<th>{% trans "Stock Item" %}</th>
<th>{% trans "Location" %}</th>
<th>{% trans "Quantity" %}</th>
<th>{% trans "Updated" %}</th>
<th><!-- Actions --></th>
</tr>
</thead>
<tbody>
`;
response.forEach(function(item) {
html += buildStockItemRow(item);
});
html += `</tbody></table>`;
constructForm(`/api/part/stocktake/`, {
preFormContent: html,
method: 'POST',
title: '{% trans "Part Stocktake" %}',
confirm: true,
fields: {
part: {
value: partId,
hidden: true,
},
quantity: {
value: part_quantity,
},
note: {},
},
onSuccess: function(response) {
handleFormSuccess(response, options);
}
method: 'POST',
title: '{% trans "Generate Stocktake Report" %}',
preFormContent: content,
fields: fields,
onSuccess: function(response) {
showMessage('{% trans "Stocktake report scheduled" %}', {
style: 'success',
});
}
}
);
}
var stocktakeChart = null;
/*
* Load chart to display part stocktake information
*/
function loadStocktakeChart(data, options={}) {
var chart = 'part-stocktake-chart';
var context = document.getElementById(chart);
var quantity_data = [];
var cost_min_data = [];
var cost_max_data = [];
var base_currency = baseCurrency();
var rate_data = getCurrencyConversionRates();
data.forEach(function(row) {
var date = moment(row.date);
quantity_data.push({
x: date,
y: row.quantity
});
if (row.cost_min) {
cost_min_data.push({
x: date,
y: convertCurrency(
row.cost_min,
row.cost_min_currency || base_currency,
base_currency,
rate_data
),
});
}
if (row.cost_max) {
cost_max_data.push({
x: date,
y: convertCurrency(
row.cost_max,
row.cost_max_currency || base_currency,
base_currency,
rate_data
),
});
}
});
var chart_data = {
datasets: [
{
label: '{% trans "Quantity" %}',
data: quantity_data,
backgroundColor: 'rgba(160, 80, 220, 0.75)',
borderWidth: 3,
borderColor: 'rgb(160, 80, 220)',
yAxisID: 'y',
},
{
label: '{% trans "Minimum Cost" %}',
data: cost_min_data,
backgroundColor: 'rgba(220, 160, 80, 0.25)',
borderWidth: 2,
borderColor: 'rgba(220, 160, 80, 0.35)',
borderDash: [10, 5],
yAxisID: 'y1',
fill: '+1',
},
{
label: '{% trans "Maximum Cost" %}',
data: cost_max_data,
backgroundColor: 'rgba(220, 160, 80, 0.25)',
borderWidth: 2,
borderColor: 'rgba(220, 160, 80, 0.35)',
borderDash: [10, 5],
yAxisID: 'y1',
fill: '-1',
}
]
};
if (stocktakeChart != null) {
stocktakeChart.destroy();
}
stocktakeChart = new Chart(context, {
type: 'scatter',
data: chart_data,
options: {
showLine: true,
scales: {
x: {
type: 'time',
// suggestedMax: today.format(),
position: 'bottom',
time: {
minUnit: 'day',
}
},
y: {
type: 'linear',
display: true,
position: 'left',
},
y1: {
type: 'linear',
display: true,
position: 'right',
}
},
}
});
}
/*
* Load table for part stocktake information
*/
function loadPartStocktakeTable(partId, options={}) {
// HTML elements
var table = options.table || '#part-stocktake-table';
var params = options.params || {};
@ -853,13 +898,32 @@ function loadPartStocktakeTable(partId, options={}) {
formatNoMatches: function() {
return '{% trans "No stocktake information available" %}';
},
onLoadSuccess: function(response) {
var data = response.results || response;
loadStocktakeChart(data);
},
columns: [
{
field: 'item_count',
title: '{% trans "Stock Items" %}',
switchable: true,
sortable: true,
},
{
field: 'quantity',
title: '{% trans "Quantity" %}',
title: '{% trans "Total Quantity" %}',
switchable: false,
sortable: true,
},
{
field: 'cost',
title: '{% trans "Total Cost" %}',
switchable: false,
formatter: function(value, row) {
return formatPriceRange(row.cost_min, row.cost_max);
}
},
{
field: 'note',
title: '{% trans "Notes" %}',
@ -883,7 +947,7 @@ function loadPartStocktakeTable(partId, options={}) {
{
field: 'actions',
title: '',
visible: options.admin,
visible: options.allow_edit || options.allow_delete,
switchable: false,
sortable: false,
formatter: function(value, row) {
@ -910,7 +974,12 @@ function loadPartStocktakeTable(partId, options={}) {
constructForm(`/api/part/stocktake/${pk}/`, {
fields: {
item_count: {},
quantity: {},
cost_min: {},
cost_min_currency: {},
cost_max: {},
cost_max_currency: {},
note: {},
},
title: '{% trans "Edit Stocktake Entry" %}',

View File

@ -205,6 +205,11 @@ function calculateTotalPrice(dataset, value_func, currency_func, options={}) {
total += value;
}
// Return raw total instead of formatted value
if (options.raw) {
return total;
}
return formatCurrency(total, {
currency: currency,
});