2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 12:35:46 +00:00

Merge pull request #1804 from SchrodingersGat/api-stock-adjustments

Api stock adjustments
This commit is contained in:
Oliver
2021-07-12 22:16:56 +10:00
committed by GitHub
13 changed files with 656 additions and 673 deletions

View File

@ -1,3 +1,6 @@
{% load i18n %}
{% load inventree_extras %}
var jQuery = window.$;
// using jQuery
@ -138,4 +141,49 @@ function inventreeDelete(url, options={}) {
inventreePut(url, {}, options);
}
function showApiError(xhr) {
var title = null;
var message = null;
switch (xhr.status) {
case 0: // No response
title = '{% trans "No Response" %}';
message = '{% trans "No response from the InvenTree server" %}';
break;
case 400: // Bad request
// Note: Normally error code 400 is handled separately,
// and should now be shown here!
title = '{% trans "Error 400: Bad request" %}';
message = '{% trans "API request returned error code 400" %}';
break;
case 401: // Not authenticated
title = '{% trans "Error 401: Not Authenticated" %}';
message = '{% trans "Authentication credentials not supplied" %}';
break;
case 403: // Permission denied
title = '{% trans "Error 403: Permission Denied" %}';
message = '{% trans "You do not have the required permissions to access this function" %}';
break;
case 404: // Resource not found
title = '{% trans "Error 404: Resource Not Found" %}';
message = '{% trans "The requested resource could not be located on the server" %}';
break;
case 408: // Timeout
title = '{% trans "Error 408: Timeout" %}';
message = '{% trans "Connection timeout while requesting data from server" %}';
break;
default:
title = '{% trans "Unhandled Error Code" %}';
message = `{% trans "Error code" %}: ${xhr.status}`;
break;
}
message += "<hr>";
message += renderErrorMessage(xhr);
showAlertDialog(title, message);
}

View File

@ -395,11 +395,11 @@ function constructFormBody(fields, options) {
for (var name in displayed_fields) {
// Only push names which are actually in the set of fields
if (name in fields) {
field_names.push(name);
} else {
console.log(`WARNING: '${name}' does not match a valid field name.`);
field_names.push(name);
// Field not specified in the API, but the client wishes to add it!
if (!(name in fields)) {
fields[name] = displayed_fields[name];
}
}
@ -422,10 +422,8 @@ function constructFormBody(fields, options) {
default:
break;
}
var f = constructField(name, field, options);
html += f;
html += constructField(name, field, options);
}
// TODO: Dynamically create the modals,
@ -441,7 +439,15 @@ function constructFormBody(fields, options) {
modalEnable(modal, true);
// Insert generated form content
$(modal).find('.modal-form-content').html(html);
$(modal).find('#form-content').html(html);
if (options.preFormContent) {
$(modal).find('#pre-form-content').html(options.preFormContent);
}
if (options.postFormContent) {
$(modal).find('#post-form-content').html(options.postFormContent);
}
// Clear any existing buttons from the modal
$(modal).find('#modal-footer-buttons').html('');
@ -474,7 +480,21 @@ function constructFormBody(fields, options) {
$(modal).on('click', '#modal-form-submit', function() {
submitFormData(fields, options);
// Immediately disable the "submit" button,
// to prevent the form being submitted multiple times!
$(options.modal).find('#modal-form-submit').prop('disabled', true);
// Run custom code before normal form submission
if (options.beforeSubmit) {
options.beforeSubmit(fields, options);
}
// Run custom code instead of normal form submission
if (options.onSubmit) {
options.onSubmit(fields, options);
} else {
submitFormData(fields, options);
}
});
}
@ -511,10 +531,6 @@ function insertConfirmButton(options) {
*/
function submitFormData(fields, options) {
// Immediately disable the "submit" button,
// to prevent the form being submitted multiple times!
$(options.modal).find('#modal-form-submit').prop('disabled', true);
// Form data to be uploaded to the server
// Only used if file / image upload is required
var form_data = new FormData();
@ -581,47 +597,9 @@ function submitFormData(fields, options) {
case 400: // Bad request
handleFormErrors(xhr.responseJSON, fields, options);
break;
case 0: // No response
$(options.modal).modal('hide');
showAlertDialog(
'{% trans "No Response" %}',
'{% trans "No response from the InvenTree server" %}',
);
break;
case 401: // Not authenticated
$(options.modal).modal('hide');
showAlertDialog(
'{% trans "Error 401: Not Authenticated" %}',
'{% trans "Authentication credentials not supplied" %}',
);
break;
case 403: // Permission denied
$(options.modal).modal('hide');
showAlertDialog(
'{% trans "Error 403: Permission Denied" %}',
'{% trans "You do not have the required permissions to access this function" %}',
);
break;
case 404: // Resource not found
$(options.modal).modal('hide');
showAlertDialog(
'{% trans "Error 404: Resource Not Found" %}',
'{% trans "The requested resource could not be located on the server" %}',
);
break;
case 408: // Timeout
$(options.modal).modal('hide');
showAlertDialog(
'{% trans "Error 408: Timeout" %}',
'{% trans "Connection timeout while requesting data from server" %}',
);
break;
default:
$(options.modal).modal('hide');
showAlertDialog('{% trans "Error requesting form data" %}', renderErrorMessage(xhr));
console.log(`WARNING: Unhandled response code - ${xhr.status}`);
showApiError(xhr);
break;
}
}
@ -697,6 +675,10 @@ function getFormFieldValue(name, field, options) {
// Find the HTML element
var el = $(options.modal).find(`#id_${name}`);
if (!el) {
return null;
}
var value = null;
switch (field.type) {
@ -834,33 +816,27 @@ function handleFormErrors(errors, fields, options) {
}
for (field_name in errors) {
if (field_name in fields) {
// Add the 'has-error' class
$(options.modal).find(`#div_id_${field_name}`).addClass('has-error');
// Add the 'has-error' class
$(options.modal).find(`#div_id_${field_name}`).addClass('has-error');
var field_dom = $(options.modal).find(`#errors-${field_name}`); // $(options.modal).find(`#id_${field_name}`);
var field_dom = $(options.modal).find(`#errors-${field_name}`); // $(options.modal).find(`#id_${field_name}`);
var field_errors = errors[field_name];
var field_errors = errors[field_name];
// Add an entry for each returned error message
for (var idx = field_errors.length-1; idx >= 0; idx--) {
// Add an entry for each returned error message
for (var idx = field_errors.length-1; idx >= 0; idx--) {
var error_text = field_errors[idx];
var error_text = field_errors[idx];
var html = `
<span id='error_${idx+1}_id_${field_name}' class='help-block form-error-message'>
<strong>${error_text}</strong>
</span>`;
var html = `
<span id='error_${idx+1}_id_${field_name}' class='help-block form-error-message'>
<strong>${error_text}</strong>
</span>`;
field_dom.append(html);
}
} else {
console.log(`WARNING: handleFormErrors found no match for field '${field_name}'`);
field_dom.append(html);
}
}
}
@ -1464,21 +1440,21 @@ function constructInputOptions(name, classes, type, parameters) {
opts.push(`readonly=''`);
}
if (parameters.value) {
if (parameters.value != null) {
// Existing value?
opts.push(`value='${parameters.value}'`);
} else if (parameters.default) {
} else if (parameters.default != null) {
// Otherwise, a defualt value?
opts.push(`value='${parameters.default}'`);
}
// Maximum input length
if (parameters.max_length) {
if (parameters.max_length != null) {
opts.push(`maxlength='${parameters.max_length}'`);
}
// Minimum input length
if (parameters.min_length) {
if (parameters.min_length != null) {
opts.push(`minlength='${parameters.min_length}'`);
}
@ -1497,8 +1473,13 @@ function constructInputOptions(name, classes, type, parameters) {
opts.push(`required=''`);
}
// Custom mouseover title?
if (parameters.title != null) {
opts.push(`title='${parameters.title}'`);
}
// Placeholder?
if (parameters.placeholder) {
if (parameters.placeholder != null) {
opts.push(`placeholder='${parameters.placeholder}'`);
}

View File

@ -12,7 +12,6 @@
*/
function createNewModal(options={}) {
var id = 1;
// Check out what modal forms are already being displayed
@ -39,12 +38,13 @@ function createNewModal(options={}) {
</h3>
</div>
<div class='modal-form-content-wrapper'>
<div id='pre-form-content'>
<!-- Content can be inserted here *before* the form fields -->
</div>
<div id='non-field-errors'>
<!-- Form error messages go here -->
</div>
<div id='pre-form-content'>
<!-- Content can be inserted here *before* the form fields -->
</div>
<div id='form-content' class='modal-form-content'>
<!-- Form content will be injected here-->
</div>
@ -102,6 +102,14 @@ function createNewModal(options={}) {
modalSetSubmitText(modal_name, options.submitText || '{% trans "Submit" %}');
modalSetCloseText(modal_name, options.cancelText || '{% trans "Cancel" %}');
if (options.hideSubmitButton) {
$(modal_name).find('#modal-form-submit').hide();
}
if (options.hideCloseButton) {
$(modal_name).find('#modal-form-cancel').hide();
}
// Return the "name" of the modal
return modal_name;
}
@ -551,25 +559,18 @@ function showAlertDialog(title, content, options={}) {
*
* title - Title text
* content - HTML content of the dialog window
* options:
* modal - modal form to use (default = '#modal-alert-dialog')
*/
var modal = options.modal || '#modal-alert-dialog';
$(modal).on('shown.bs.modal', function() {
$(modal + ' .modal-form-content').scrollTop(0);
var modal = createNewModal({
title: title,
cancelText: '{% trans "Close" %}',
hideSubmitButton: true,
});
modalSetTitle(modal, title);
modalSetContent(modal, content);
modalSetContent(modal, content);
$(modal).modal({
backdrop: 'static',
keyboard: false,
});
$(modal).modal('show');
$(modal).modal('show');
}
@ -586,22 +587,15 @@ function showQuestionDialog(title, content, options={}) {
* cancel - Functino to run if the user presses 'Cancel'
*/
var modal = options.modal || '#modal-question-dialog';
$(modal).on('shown.bs.modal', function() {
$(modal + ' .modal-form-content').scrollTop(0);
var modal = createNewModal({
title: title,
submitText: options.accept_text || '{% trans "Accept" %}',
cancelText: options.cancel_text || '{% trans "Cancel" %}',
});
modalSetTitle(modal, title);
modalSetContent(modal, content);
var accept_text = options.accept_text || '{% trans "Accept" %}';
var cancel_text = options.cancel_text || '{% trans "Cancel" %}';
$(modal).find('#modal-form-cancel').html(cancel_text);
$(modal).find('#modal-form-accept').html(accept_text);
$(modal).on('click', '#modal-form-accept', function() {
$(modal).on('click', "#modal-form-submit", function() {
$(modal).modal('hide');
if (options.accept) {
@ -609,14 +603,6 @@ function showQuestionDialog(title, content, options={}) {
}
});
$(modal).on('click', 'modal-form-cancel', function() {
$(modal).modal('hide');
if (options.cancel) {
options.cancel();
}
});
$(modal).modal('show');
}

View File

@ -20,6 +20,315 @@ function stockStatusCodes() {
}
/**
* Perform stock adjustments
*/
function adjustStock(action, items, options={}) {
var formTitle = 'Form Title Here';
var actionTitle = null;
// API url
var url = null;
var specifyLocation = false;
var allowSerializedStock = false;
switch (action) {
case 'move':
formTitle = '{% trans "Transfer Stock" %}';
actionTitle = '{% trans "Move" %}';
specifyLocation = true;
allowSerializedStock = true;
url = '{% url "api-stock-transfer" %}';
break;
case 'count':
formTitle = '{% trans "Count Stock" %}';
actionTitle = '{% trans "Count" %}';
url = '{% url "api-stock-count" %}';
break;
case 'take':
formTitle = '{% trans "Remove Stock" %}';
actionTitle = '{% trans "Take" %}';
url = '{% url "api-stock-remove" %}';
break;
case 'add':
formTitle = '{% trans "Add Stock" %}';
actionTitle = '{% trans "Add" %}';
url = '{% url "api-stock-add" %}';
break;
case 'delete':
formTitle = '{% trans "Delete Stock" %}';
allowSerializedStock = true;
break;
default:
break;
}
// Generate modal HTML content
var html = `
<table class='table table-striped table-condensed' id='stock-adjust-table'>
<thead>
<tr>
<th>{% trans "Part" %}</th>
<th>{% trans "Stock" %}</th>
<th>{% trans "Location" %}</th>
<th>${actionTitle || ''}</th>
<th></th>
</tr>
</thead>
<tbody>
`;
var itemCount = 0;
for (var idx = 0; idx < items.length; idx++) {
var item = items[idx];
if ((item.serial != null) && !allowSerializedStock) {
continue;
}
var pk = item.pk;
var readonly = (item.serial != null);
var minValue = null;
var maxValue = null;
var value = null;
switch (action) {
case 'move':
minValue = 0;
maxValue = item.quantity;
value = item.quantity;
break;
case 'add':
minValue = 0;
value = 0;
break;
case 'take':
minValue = 0;
value = 0;
break;
case 'count':
minValue = 0;
value = item.quantity;
break;
default:
break;
}
var image = item.part_detail.thumbnail || item.part_detail.image || blankImage();
var status = stockStatusDisplay(item.status, {
classes: 'float-right'
});
var quantity = item.quantity;
var location = locationDetail(item, false);
if (item.location_detail) {
location = item.location_detail.pathstring;
}
if (item.serial != null) {
quantity = `#${item.serial}`;
}
var actionInput = '';
if (actionTitle != null) {
actionInput = constructNumberInput(
item.pk,
{
value: value,
min_value: minValue,
max_value: maxValue,
read_only: readonly,
title: readonly ? '{% trans "Quantity cannot be adjusted for serialized stock" %}' : '{% trans "Specify stock quantity" %}',
}
)
};
var buttons = `<div class='btn-group float-right' role='group'>`;
buttons += makeIconButton(
'fa-times icon-red',
'button-stock-item-remove',
pk,
'{% trans "Remove stock item" %}',
);
buttons += `</div>`;
html += `
<tr id='stock_item_${pk}' class='stock-item-row'>
<td id='part_${pk}'><img src='${image}' class='hover-img-thumb'> ${item.part_detail.full_name}</td>
<td id='stock_${pk}'>${quantity}${status}</td>
<td id='location_${pk}'>${location}</td>
<td id='action_${pk}'>
<div id='div_id_${pk}'>
${actionInput}
<div id='errors-${pk}'></div>
</div>
</td>
<td id='buttons_${pk}'>${buttons}</td>
</tr>`;
itemCount += 1;
}
if (itemCount == 0) {
showAlertDialog(
'{% trans "Select Stock Items" %}',
'{% trans "You must select at least one available stock item" %}',
);
return;
}
html += `</tbody></table>`;
var modal = createNewModal({
title: formTitle,
});
// Extra fields
var extraFields = {
location: {
label: '{% trans "Location" %}',
help_text: '{% trans "Select destination stock location" %}',
type: 'related field',
required: true,
api_url: `/api/stock/location/`,
model: 'stocklocation',
},
notes: {
label: '{% trans "Notes" %}',
help_text: '{% trans "Stock transaction notes" %}',
type: 'string',
}
};
if (!specifyLocation) {
delete extraFields.location;
}
constructFormBody({}, {
preFormContent: html,
fields: extraFields,
confirm: true,
confirmMessage: '{% trans "Confirm stock adjustment" %}',
modal: modal,
onSubmit: function(fields, opts) {
// "Delete" action gets handled differently
if (action == 'delete') {
var requests = [];
items.forEach(function(item) {
requests.push(
inventreeDelete(
`/api/stock/${item.pk}/`,
)
)
});
// Wait for *all* the requests to complete
$.when.apply($, requests).then(function() {
// Destroy the modal window
$(modal).modal('hide');
if (options.onSuccess) {
options.onSuccess();
}
});
return;
}
// Data to transmit
var data = {
items: [],
};
// Add values for each selected stock item
items.forEach(function(item) {
var q = getFormFieldValue(item.pk, {}, {modal: modal});
if (q != null) {
data.items.push({pk: item.pk, quantity: q});
}
});
// Add in extra field data
for (field_name in extraFields) {
data[field_name] = getFormFieldValue(
field_name,
fields[field_name],
{
modal: modal,
}
);
}
inventreePut(
url,
data,
{
method: 'POST',
success: function(response, status) {
// Destroy the modal window
$(modal).modal('hide');
if (options.onSuccess) {
options.onSuccess();
}
},
error: function(xhr) {
switch (xhr.status) {
case 400:
// Handle errors for standard fields
handleFormErrors(
xhr.responseJSON,
extraFields,
{
modal: modal,
}
)
break;
default:
$(modal).modal('hide');
showApiError(xhr);
break;
}
}
}
);
}
});
// Attach callbacks for the action buttons
$(modal).find('.button-stock-item-remove').click(function() {
var pk = $(this).attr('pk');
$(modal).find(`#stock_item_${pk}`).remove();
});
attachToggle(modal);
$(modal + ' .select2-container').addClass('select-full-width');
$(modal + ' .select2-container').css('width', '100%');
}
function removeStockRow(e) {
// Remove a selected row from a stock modal form
@ -228,6 +537,58 @@ function loadStockTestResultsTable(table, options) {
}
function locationDetail(row, showLink=true) {
/*
* Function to display a "location" of a StockItem.
*
* Complicating factors: A StockItem may not actually *be* in a location!
* - Could be at a customer
* - Could be installed in another stock item
* - Could be assigned to a sales order
* - Could be currently in production!
*
* So, instead of being naive, we'll check!
*/
// Display text
var text = '';
// URL (optional)
var url = '';
if (row.is_building && row.build) {
// StockItem is currently being built!
text = '{% trans "In production" %}';
url = `/build/${row.build}/`;
} else if (row.belongs_to) {
// StockItem is installed inside a different StockItem
text = `{% trans "Installed in Stock Item" %} ${row.belongs_to}`;
url = `/stock/item/${row.belongs_to}/installed/`;
} else if (row.customer) {
// StockItem has been assigned to a customer
text = '{% trans "Shipped to customer" %}';
url = `/company/${row.customer}/assigned-stock/`;
} else if (row.sales_order) {
// StockItem has been assigned to a sales order
text = '{% trans "Assigned to Sales Order" %}';
url = `/order/sales-order/${row.sales_order}/`;
} else if (row.location) {
text = row.location_detail.pathstring;
url = `/stock/location/${row.location}/`;
} else {
text = '<i>{% trans "No stock location set" %}</i>';
url = '';
}
if (showLink && url) {
return renderLink(text, url);
} else {
return text;
}
}
function loadStockTable(table, options) {
/* Load data into a stock table with adjustable options.
* Fetches data (via AJAX) and loads into a bootstrap table.
@ -271,56 +632,6 @@ function loadStockTable(table, options) {
filters[key] = params[key];
}
function locationDetail(row) {
/*
* Function to display a "location" of a StockItem.
*
* Complicating factors: A StockItem may not actually *be* in a location!
* - Could be at a customer
* - Could be installed in another stock item
* - Could be assigned to a sales order
* - Could be currently in production!
*
* So, instead of being naive, we'll check!
*/
// Display text
var text = '';
// URL (optional)
var url = '';
if (row.is_building && row.build) {
// StockItem is currently being built!
text = '{% trans "In production" %}';
url = `/build/${row.build}/`;
} else if (row.belongs_to) {
// StockItem is installed inside a different StockItem
text = `{% trans "Installed in Stock Item" %} ${row.belongs_to}`;
url = `/stock/item/${row.belongs_to}/installed/`;
} else if (row.customer) {
// StockItem has been assigned to a customer
text = '{% trans "Shipped to customer" %}';
url = `/company/${row.customer}/assigned-stock/`;
} else if (row.sales_order) {
// StockItem has been assigned to a sales order
text = '{% trans "Assigned to Sales Order" %}';
url = `/order/sales-order/${row.sales_order}/`;
} else if (row.location) {
text = row.location_detail.pathstring;
url = `/stock/location/${row.location}/`;
} else {
text = '<i>{% trans "No stock location set" %}</i>';
url = '';
}
if (url) {
return renderLink(text, url);
} else {
return text;
}
}
var grouping = true;
if ('grouping' in options) {
@ -741,39 +1052,15 @@ function loadStockTable(table, options) {
]
);
function stockAdjustment(action) {
var items = $("#stock-table").bootstrapTable("getSelections");
var stock = [];
items.forEach(function(item) {
stock.push(item.pk);
});
// Buttons for launching secondary modals
var secondary = [];
if (action == 'move') {
secondary.push({
field: 'destination',
label: '{% trans "New Location" %}',
title: '{% trans "Create new location" %}',
url: "/stock/location/new/",
});
}
launchModalForm("/stock/adjust/",
{
data: {
action: action,
stock: stock,
},
success: function() {
$("#stock-table").bootstrapTable('refresh');
},
secondary: secondary,
adjustStock(action, items, {
onSuccess: function() {
$('#stock-table').bootstrapTable('refresh');
}
);
});
}
// Automatically link button callbacks

View File

@ -56,46 +56,4 @@
</div>
</div>
</div>
</div>
<div class='modal fade modal-fixed-footer' role='dialog' id='modal-question-dialog'>
<div class='modal-dialog'>
<div class='modal-content'>
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h3 id='modal-title'>Question Here</h3>
</div>
<div class='modal-form-content'>
</div>
<div class='modal-footer'>
<div id='modal-footer-buttons'></div>
<button type='button' class='btn btn-default' id='modal-form-cancel' data-dismiss='modal'>{% trans "Cancel" %}</button>
<button type='button' class='btn btn-primary' id='modal-form-accept'>{% trans "Accept" %}</button>
</div>
</div>
</div>
</div>
<div class='modal fade modal-fixed-footer' role='dialog' id='modal-alert-dialog'>
<div class='modal-dialog'>
<div class='modal-content'>
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<h3 id='modal-title'>Alert Information</h3>
</div>
<div class='modal-form-content-wrapper'>
<div class='modal-form-content'>
</div>
</div>
<div class='modal-footer'>
<div id='modal-footer-buttons'></div>
<button type='button' class='btn btn-default' data-dismiss='modal'>{% trans "Close" %}</button>
</div>
</div>
</div>
</div>
</div>