- {% include "filter_list.html" with id="order-lines" %}
+ {% include "filter_list.html" with id="purchase-order-lines" %}
@@ -190,6 +190,10 @@ $('#new-po-line').click(function() {
$('#receive-selected-items').click(function() {
var items = $("#po-line-table").bootstrapTable('getSelections');
+ if (items.length == 0) {
+ items = $("#po-line-table").bootstrapTable('getData');
+ }
+
receivePurchaseOrderItems(
{{ order.id }},
items,
diff --git a/InvenTree/order/templates/order/sales_order_base.html b/InvenTree/order/templates/order/sales_order_base.html
index 2a5a79a161..c8718d54d8 100644
--- a/InvenTree/order/templates/order/sales_order_base.html
+++ b/InvenTree/order/templates/order/sales_order_base.html
@@ -63,8 +63,8 @@ src="{% static 'img/blank_image.png' %}"
{% if order.status == SalesOrderStatus.PENDING %}
-
`;
+
+ constructForm(`/api/order/so/${order_id}/allocate/`, {
+ method: 'POST',
+ fields: {
+ shipment: {
+ filters: {
+ order: order_id,
+ shipped: false,
+ },
+ value: options.shipment || null,
+ auto_fill: true,
+ }
+ },
+ preFormContent: html,
+ confirm: true,
+ confirmMessage: '{% trans "Confirm stock allocation" %}',
+ title: '{% trans "Allocate Stock Items to Sales Order" %}',
+ afterRender: function(fields, opts) {
+
+ // Initialize source location field
+ var take_from_field = {
+ name: 'take_from',
+ model: 'stocklocation',
+ api_url: '{% url "api-location-list" %}',
+ required: false,
+ type: 'related field',
+ value: options.source_location || null,
+ noResults: function(query) {
+ return '{% trans "No matching stock locations" %}';
+ },
+ };
+
+ initializeRelatedField(
+ take_from_field,
+ null,
+ opts
+ );
+
+ // Add callback to "clear" button for take_from field
+ addClearCallback(
+ 'take_from',
+ take_from_field,
+ opts,
+ );
+
+ // Initialize fields for each line item
+ line_items.forEach(function(line_item) {
+ var pk = line_item.pk;
+
+ initializeRelatedField(
+ {
+ name: `items_stock_item_${pk}`,
+ api_url: '{% url "api-stock-list" %}',
+ filters: {
+ part: line_item.part,
+ in_stock: true,
+ part_detail: true,
+ location_detail: true,
+ available: true,
+ },
+ model: 'stockitem',
+ required: true,
+ render_part_detail: true,
+ render_location_detail: true,
+ auto_fill: true,
+ onSelect: function(data, field, opts) {
+ // Adjust the 'quantity' field based on availability
+
+ if (!('quantity' in data)) {
+ return;
+ }
+
+ // Calculate the available quantity
+ var available = Math.max((data.quantity || 0) - (data.allocated || 0), 0);
+
+ // Remaining quantity to be allocated?
+ var remaining = opts.quantity || available;
+
+ // Maximum amount that we need
+ var desired = Math.min(available, remaining);
+
+ updateFieldValue(`items_quantity_${pk}`, desired, {}, opts);
+
+ },
+ adjustFilters: function(filters) {
+ // Restrict query to the selected location
+ var location = getFormFieldValue(
+ 'take_from',
+ {},
+ {
+ modal: opts.modal,
+ }
+ );
+
+ filters.location = location;
+ filters.cascade = true;
+
+ // Exclude expired stock?
+ if (global_settings.STOCK_ENABLE_EXPIRY && !global_settings.STOCK_ALLOW_EXPIRED_SALE) {
+ filters.expired = false;
+ }
+
+ return filters;
+ },
+ noResults: function(query) {
+ return '{% trans "No matching stock items" %}';
+ }
+ },
+ null,
+ opts
+ );
+ });
+
+ // Add remove-row button callbacks
+ $(opts.modal).find('.button-row-remove').click(function() {
+ var pk = $(this).attr('pk');
+
+ $(opts.modal).find(`#allocation_row_${pk}`).remove();
+ });
+ },
+ onSubmit: function(fields, opts) {
+ // Extract data elements from the form
+ var data = {
+ items: [],
+ shipment: getFormFieldValue(
+ 'shipment',
+ {},
+ opts
+ )
+ };
+
+ var item_pk_values = [];
+
+ line_items.forEach(function(item) {
+
+ var pk = item.pk;
+
+ var quantity = getFormFieldValue(
+ `items_quantity_${pk}`,
+ {},
+ opts
+ );
+
+ var stock_item = getFormFieldValue(
+ `items_stock_item_${pk}`,
+ {},
+ opts
+ );
+
+ if (quantity != null) {
+ data.items.push({
+ line_item: pk,
+ stock_item: stock_item,
+ quantity: quantity,
+ });
+
+ item_pk_values.push(pk);
+ }
+ });
+
+ // Provide nested values
+ opts.nested = {
+ 'items': item_pk_values
+ };
+
+ inventreePut(
+ opts.url,
+ data,
+ {
+ method: 'POST',
+ success: function(response) {
+ $(opts.modal).modal('hide');
+
+ if (options.success) {
+ options.success(response);
+ }
+ },
+ error: function(xhr) {
+ switch (xhr.status) {
+ case 400:
+ handleFormErrors(xhr.responseJSON, fields, opts);
+ break;
+ default:
+ $(opts.modal).modal('hide');
+ showApiError(xhr);
+ break;
+ }
+ }
+ }
+ );
+ },
+ });
+}
+
+
function loadSalesOrderAllocationTable(table, options={}) {
/**
* Load a table with SalesOrderAllocation items
@@ -1145,7 +1800,7 @@ function loadSalesOrderAllocationTable(table, options={}) {
$(table).inventreeTable({
url: '{% url "api-so-allocation-list" %}',
queryParams: filters,
- name: 'salesorderallocation',
+ name: options.name || 'salesorderallocation',
groupBy: false,
search: false,
paginationVAlign: 'bottom',
@@ -1203,7 +1858,7 @@ function loadSalesOrderAllocationTable(table, options={}) {
field: 'quantity',
title: '{% trans "Quantity" %}',
sortable: true,
- }
+ },
]
});
}
@@ -1220,17 +1875,13 @@ function showAllocationSubTable(index, row, element, options) {
// Construct a sub-table element
var html = `
`;
element.html(html);
var table = $(`#allocation-table-${row.pk}`);
- // Is the parent SalesOrder pending?
- var pending = options.status == {{ SalesOrderStatus.PENDING }};
-
function setupCallbacks() {
// Add callbacks for 'edit' buttons
table.find('.button-allocation-edit').click(function() {
@@ -1275,11 +1926,18 @@ function showAllocationSubTable(index, row, element, options) {
table.bootstrapTable({
onPostBody: setupCallbacks,
data: row.allocations,
- showHeader: false,
+ showHeader: true,
columns: [
+ {
+ field: 'part_detail',
+ title: '{% trans "Part" %}',
+ formatter: function(part, row) {
+ return imageHoverIcon(part.thumbnail) + renderLink(part.full_name, `/part/${part.pk}/`);
+ }
+ },
{
field: 'allocated',
- title: '{% trans "Quantity" %}',
+ title: '{% trans "Stock Item" %}',
formatter: function(value, row, index, field) {
var text = '';
@@ -1297,32 +1955,30 @@ function showAllocationSubTable(index, row, element, options) {
title: '{% trans "Location" %}',
formatter: function(value, row, index, field) {
- // Location specified
- if (row.location) {
+ if (row.shipment_date) {
+ return `
{% trans "Shipped to customer" %} - ${row.shipment_date}`;
+ } else if (row.location) {
+ // Location specified
return renderLink(
row.location_detail.pathstring || '{% trans "Location" %}',
`/stock/location/${row.location}/`
);
} else {
- return `
{% trans "Stock location not specified" %}`;
+ return `{% trans "Stock location not specified" %}`;
}
},
},
- // TODO: ?? What is 'po' field all about?
- /*
- {
- field: 'po'
- },
- */
{
field: 'buttons',
- title: '{% trans "Actions" %}',
+ title: '{% trans "" %}',
formatter: function(value, row, index, field) {
var html = ``;
var pk = row.pk;
- if (pending) {
+ if (row.shipment_date) {
+ html += `{% trans "Shipped" %}`;
+ } else {
html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
}
@@ -1361,8 +2017,9 @@ function showFulfilledSubTable(index, row, element, options) {
queryParams: {
part: row.part,
sales_order: options.order,
+ location_detail: true,
},
- showHeader: false,
+ showHeader: true,
columns: [
{
field: 'pk',
@@ -1370,6 +2027,7 @@ function showFulfilledSubTable(index, row, element, options) {
},
{
field: 'stock',
+ title: '{% trans "Stock Item" %}',
formatter: function(value, row) {
var text = '';
if (row.serial && row.quantity == 1) {
@@ -1381,11 +2039,25 @@ function showFulfilledSubTable(index, row, element, options) {
return renderLink(text, `/stock/item/${row.pk}/`);
},
},
- /*
{
- field: 'po'
- },
- */
+ field: 'location',
+ title: '{% trans "Location" %}',
+ formatter: function(value, row) {
+ if (row.customer) {
+ return renderLink(
+ '{% trans "Shipped to customer" %}',
+ `/company/${row.customer}/`
+ );
+ } else if (row.location && row.location_detail) {
+ return renderLink(
+ row.location_detail.pathstring,
+ `/stock/location/${row.location}`,
+ );
+ } else {
+ return `{% trans "Stock location not specified" %}`;
+ }
+ }
+ }
],
});
}
@@ -1429,7 +2101,7 @@ function loadSalesOrderLineItemTable(table, options={}) {
var filter_target = options.filter_target || '#filter-list-sales-order-lines';
- setupFilterList('salesorderlineitems', $(table), filter_target);
+ setupFilterList('salesorderlineitem', $(table), filter_target);
// Is the order pending?
var pending = options.status == {{ SalesOrderStatus.PENDING }};
@@ -1451,7 +2123,7 @@ function loadSalesOrderLineItemTable(table, options={}) {
*/
{
sortable: true,
- sortName: 'part__name',
+ sortName: 'part_detail.name',
field: 'part',
title: '{% trans "Part" %}',
switchable: false,
@@ -1548,40 +2220,65 @@ function loadSalesOrderLineItemTable(table, options={}) {
},
},
);
+
+ columns.push(
+ {
+ field: 'allocated',
+ title: '{% trans "Allocated" %}',
+ switchable: false,
+ sortable: true,
+ formatter: function(value, row, index, field) {
+ return makeProgressBar(row.allocated, row.quantity, {
+ id: `order-line-progress-${row.pk}`,
+ });
+ },
+ sorter: function(valA, valB, rowA, rowB) {
+
+ var A = rowA.allocated;
+ var B = rowB.allocated;
+
+ if (A == 0 && B == 0) {
+ return (rowA.quantity > rowB.quantity) ? 1 : -1;
+ }
+
+ var progressA = parseFloat(A) / rowA.quantity;
+ var progressB = parseFloat(B) / rowB.quantity;
+
+ return (progressA < progressB) ? 1 : -1;
+ }
+ },
+ );
}
- columns.push(
- {
- field: 'allocated',
- title: pending ? '{% trans "Allocated" %}' : '{% trans "Fulfilled" %}',
- switchable: false,
- formatter: function(value, row, index, field) {
+ columns.push({
+ field: 'shipped',
+ title: '{% trans "Shipped" %}',
+ switchable: false,
+ sortable: true,
+ formatter: function(value, row) {
+ return makeProgressBar(row.shipped, row.quantity, {
+ id: `order-line-shipped-${row.pk}`
+ });
+ },
+ sorter: function(valA, valB, rowA, rowB) {
+ var A = rowA.shipped;
+ var B = rowB.shipped;
- var quantity = pending ? row.allocated : row.fulfilled;
- return makeProgressBar(quantity, row.quantity, {
- id: `order-line-progress-${row.pk}`,
- });
- },
- sorter: function(valA, valB, rowA, rowB) {
-
- var A = pending ? rowA.allocated : rowA.fulfilled;
- var B = pending ? rowB.allocated : rowB.fulfilled;
-
- if (A == 0 && B == 0) {
- return (rowA.quantity > rowB.quantity) ? 1 : -1;
- }
-
- var progressA = parseFloat(A) / rowA.quantity;
- var progressB = parseFloat(B) / rowB.quantity;
-
- return (progressA < progressB) ? 1 : -1;
+ if (A == 0 && B == 0) {
+ return (rowA.quantity > rowB.quantity) ? 1 : -1;
}
- },
- {
- field: 'notes',
- title: '{% trans "Notes" %}',
- },
- );
+
+ var progressA = parseFloat(A) / rowA.quantity;
+ var progressB = parseFloat(B) / rowB.quantity;
+
+ return (progressA < progressB) ? 1 : -1;
+ }
+ });
+
+ columns.push({
+ field: 'notes',
+ title: '{% trans "Notes" %}',
+ });
if (pending) {
columns.push({
@@ -1614,7 +2311,21 @@ function loadSalesOrderLineItemTable(table, options={}) {
}
html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}');
- html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, '{% trans "Delete line item " %}');
+
+ var delete_disabled = false;
+
+ var title = '{% trans "Delete line item" %}';
+
+ if (!!row.shipped) {
+ delete_disabled = true;
+ title = '{% trans "Cannot be deleted as items have been shipped" %}';
+ } else if (!!row.allocated) {
+ delete_disabled = true;
+ title = '{% trans "Cannot be deleted as items have been allocated" %}';
+ }
+
+ // Prevent deletion of the line item if items have been allocated or shipped!
+ html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, title, {disabled: delete_disabled});
html += `
`;
@@ -1662,15 +2373,30 @@ function loadSalesOrderLineItemTable(table, options={}) {
$(table).find('.button-add-by-sn').click(function() {
var pk = $(this).attr('pk');
- // TODO: Migrate this form to the API forms
inventreeGet(`/api/order/so-line/${pk}/`, {},
{
success: function(response) {
- launchModalForm('{% url "so-assign-serials" %}', {
- success: reloadTable,
- data: {
- line: pk,
- part: response.part,
+
+ constructForm(`/api/order/so/${options.order}/allocate-serials/`, {
+ method: 'POST',
+ title: '{% trans "Allocate Serial Numbers" %}',
+ fields: {
+ line_item: {
+ value: pk,
+ hidden: true,
+ },
+ quantity: {},
+ serial_numbers: {},
+ shipment: {
+ filters: {
+ order: options.order,
+ shipped: false,
+ },
+ auto_fill: true,
+ }
+ },
+ onSuccess: function() {
+ $(table).bootstrapTable('refresh');
}
});
}
@@ -1684,61 +2410,19 @@ function loadSalesOrderLineItemTable(table, options={}) {
var line_item = $(table).bootstrapTable('getRowByUniqueId', pk);
- // Quantity remaining to be allocated
- var remaining = (line_item.quantity || 0) - (line_item.allocated || 0);
-
- if (remaining < 0) {
- remaining = 0;
- }
-
- var fields = {
- // SalesOrderLineItem reference
- line: {
- hidden: true,
- value: pk,
- },
- item: {
- filters: {
- part_detail: true,
- location_detail: true,
- in_stock: true,
- part: line_item.part,
- exclude_so_allocation: options.order,
- },
- auto_fill: true,
- onSelect: function(data, field, opts) {
- // Quantity available from this stock item
-
- if (!('quantity' in data)) {
- return;
- }
-
- // Calculate the available quantity
- var available = Math.max((data.quantity || 0) - (data.allocated || 0), 0);
-
- // Maximum amount that we need
- var desired = Math.min(available, remaining);
-
- updateFieldValue('quantity', desired, {}, opts);
- }
- },
- quantity: {
- value: remaining,
- },
- };
-
- // Exclude expired stock?
- if (global_settings.STOCK_ENABLE_EXPIRY && !global_settings.STOCK_ALLOW_EXPIRED_SALE) {
- fields.item.filters.expired = false;
- }
-
- constructForm(
- `/api/order/so-allocation/`,
+ allocateStockToSalesOrder(
+ options.order,
+ [
+ line_item
+ ],
{
- method: 'POST',
- fields: fields,
- title: '{% trans "Allocate Stock Item" %}',
- onSuccess: reloadTable,
+ success: function() {
+ // Reload this table
+ $(table).bootstrapTable('refresh');
+
+ // Reload the pending shipment table
+ $('#pending-shipments-table').bootstrapTable('refresh');
+ }
}
);
});
@@ -1809,7 +2493,7 @@ function loadSalesOrderLineItemTable(table, options={}) {
$(table).inventreeTable({
onPostBody: setupCallbacks,
name: 'salesorderlineitems',
- sidePagination: 'server',
+ sidePagination: 'client',
formatNoMatches: function() {
return '{% trans "No matching line items" %}';
},
@@ -1825,7 +2509,7 @@ function loadSalesOrderLineItemTable(table, options={}) {
// Order is pending
return row.allocated > 0;
} else {
- return row.fulfilled > 0;
+ return row.shipped > 0;
}
},
detailFormatter: function(index, row, element) {
diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js
index 02ea3c2c3b..d6de4fdd45 100644
--- a/InvenTree/templates/js/translated/stock.js
+++ b/InvenTree/templates/js/translated/stock.js
@@ -95,6 +95,9 @@ function serializeStockItem(pk, options={}) {
});
}
+ options.confirm = true;
+ options.confirmMessage = '{% trans "Confirm Stock Serialization" %}';
+
constructForm(url, options);
}
@@ -1275,7 +1278,14 @@ function loadStockTable(table, options) {
}
if (row.allocated) {
- html += makeIconBadge('fa-bookmark', '{% trans "Stock item has been allocated" %}');
+
+ if (row.serial != null && row.quantity == 1) {
+ html += makeIconBadge('fa-bookmark icon-yellow', '{% trans "Serialized stock item has been allocated" %}');
+ } else if (row.allocated >= row.quantity) {
+ html += makeIconBadge('fa-bookmark icon-yellow', '{% trans "Stock item has been fully allocated" %}');
+ } else {
+ html += makeIconBadge('fa-bookmark', '{% trans "Stock item has been partially allocated" %}');
+ }
}
if (row.belongs_to) {
diff --git a/InvenTree/templates/js/translated/table_filters.js b/InvenTree/templates/js/translated/table_filters.js
index 409192f74d..b7ba79e498 100644
--- a/InvenTree/templates/js/translated/table_filters.js
+++ b/InvenTree/templates/js/translated/table_filters.js
@@ -173,6 +173,11 @@ function getAvailableTableFilters(tableKey) {
title: '{% trans "Is allocated" %}',
description: '{% trans "Item has been allocated" %}',
},
+ available: {
+ type: 'bool',
+ title: '{% trans "Available" %}',
+ description: '{% trans "Stock is available for use" %}',
+ },
cascade: {
type: 'bool',
title: '{% trans "Include sublocations" %}',
@@ -293,6 +298,10 @@ function getAvailableTableFilters(tableKey) {
type: 'bool',
title: '{% trans "Overdue" %}',
},
+ assigned_to_me: {
+ type: 'bool',
+ title: '{% trans "Assigned to me" %}',
+ },
};
}
@@ -305,6 +314,7 @@ function getAvailableTableFilters(tableKey) {
},
};
}
+
// Filters for the PurchaseOrder table
if (tableKey == 'purchaseorder') {
@@ -321,6 +331,10 @@ function getAvailableTableFilters(tableKey) {
type: 'bool',
title: '{% trans "Overdue" %}',
},
+ assigned_to_me: {
+ type: 'bool',
+ title: '{% trans "Assigned to me" %}',
+ },
};
}
@@ -341,6 +355,15 @@ function getAvailableTableFilters(tableKey) {
};
}
+ if (tableKey == 'salesorderlineitem') {
+ return {
+ completed: {
+ type: 'bool',
+ title: '{% trans "Completed" %}',
+ },
+ };
+ }
+
if (tableKey == 'supplier-part') {
return {
active: {
diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 48644e1889..e9d0a62d8e 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -134,9 +134,10 @@ class RuleSet(models.Model):
'sales_order': [
'company_company',
'order_salesorder',
+ 'order_salesorderallocation',
'order_salesorderattachment',
'order_salesorderlineitem',
- 'order_salesorderallocation',
+ 'order_salesordershipment',
]
}
@@ -476,6 +477,34 @@ class Owner(models.Model):
owner: Returns the Group or User instance combining the owner_type and owner_id fields
"""
+ @classmethod
+ def get_owners_matching_user(cls, user):
+ """
+ Return all "owner" objects matching the provided user:
+
+ A) An exact match for the user
+ B) Any groups that the user is a part of
+ """
+
+ user_type = ContentType.objects.get(app_label='auth', model='user')
+ group_type = ContentType.objects.get(app_label='auth', model='group')
+
+ owners = []
+
+ try:
+ owners.append(cls.objects.get(owner_id=user.pk, owner_type=user_type))
+ except:
+ pass
+
+ for group in user.groups.all():
+ try:
+ owner = cls.objects.get(owner_id=group.pk, owner_type=group_type)
+ owners.append(owner)
+ except:
+ pass
+
+ return owners
+
@staticmethod
def get_api_url():
return reverse('api-owner-list')